diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e30b8f..96e67f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,8 @@ jobs: run: go vet ./... - name: Check shell syntax and style run: | - sh -n runner/*.sh scripts/*.sh - shellcheck runner/*.sh scripts/*.sh + sh -n runner/*.sh scripts/*.sh testdata/tracer-failure/*.sh + shellcheck runner/*.sh scripts/*.sh testdata/tracer-failure/*.sh - name: Validate workflows and JSON contracts run: | go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e52d38..cb69093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes will be documented here. The project follows Keep a Changelo 9. Root owned trace channel separated from the package uid 10. Semantic JSON Schema conformance tests 11. Explicit `attestation: none` provenance boundary for unsigned profiles +12. Separate user guide, platform support guide, technical reference, and security audit record ### Security @@ -24,3 +25,8 @@ All notable changes will be documented here. The project follows Keep a Changelo 2. Empty, incomplete, timed out, truncated, malformed, or sentinel missing traces are rejected. 3. Package input cannot control Docker flags, images, mounts, environment names, or container names. 4. External traces require explicit acknowledgement before comparison. +5. Runner and prepared package images execute by immutable Docker content ID after resolution. +6. Any `strace` diagnostic fails capture before a trusted completion footer is emitted. +7. Runtime profile validation now enforces npm integrity, content ID, coverage, and behavior limits. +8. Path normalization applies only at genuine disposable root boundaries. +9. Docker client proxy variables are explicitly cleared in every runner container. diff --git a/Makefile b/Makefile index c7548df..34868a9 100644 --- a/Makefile +++ b/Makefile @@ -19,9 +19,9 @@ check: test -z "$$(gofmt -l cmd internal schemas)" go vet ./... go test -race ./... - sh -n runner/*.sh scripts/*.sh + sh -n runner/*.sh scripts/*.sh testdata/tracer-failure/*.sh command -v shellcheck >/dev/null - shellcheck runner/*.sh scripts/*.sh + shellcheck runner/*.sh scripts/*.sh testdata/tracer-failure/*.sh go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 jq empty schemas/*.json testdata/npm-fixture/seed/*.json testdata/npm-fixture/seed/node_modules/behaviorlock-fixture/*.json diff --git a/README.md b/README.md index c75865b..f6ebdd7 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,79 @@ # BehaviorLock -BehaviorLock compares selected install lifecycle system calls observed for two exact versions of a public npm package. +BehaviorLock shows how the observed install behavior of an npm package changes between two exact versions. -It records a bounded subset of path based file calls, executable launches, and connection attempts exercised during `preinstall`, `install`, and `postinstall`. It then produces a normalized profile and a reviewable version diff for CI. +Source review and vulnerability databases answer important questions about a dependency update. BehaviorLock asks another one: did the new version begin reading a credential path, starting a shell, changing files, or attempting a network connection when its install scripts ran? -> [!WARNING] -> The Docker capture backend is experimental. It is best effort observability, not a malware sandbox. Do not run unknown hostile packages on a personal workstation. Use an ephemeral GitHub hosted runner or a disposable virtual machine. BehaviorLock does not prove that a package is safe. - -## Why this exists - -Dependency updates often receive a source diff and a vulnerability database lookup. Those checks can miss a simple question: did the new version begin doing something the previous version never did? - -BehaviorLock answers that narrow question with inspectable, environment qualified evidence. It records the resolved dependency lock digest and runner identity. A new shell process, credential path read, selected filesystem mutation, or connection attempt can become visible before a baseline changes. - -## Current scope +It records selected Linux system calls from both versions, normalizes the results, and produces a diff that a person can inspect or a CI job can evaluate. -Version `0.1.0-dev` deliberately supports one workflow: +> [!WARNING] +> BehaviorLock is an experimental observation tool. It is not a malware sandbox and does not prove that a package is safe. Unknown packages belong on an ephemeral GitHub hosted runner or a disposable virtual machine, never on a personal workstation. + +## A simple example + +Imagine that version 1.0.0 creates a cache directory during installation. Version 1.1.0 does the same thing, but also starts a shell and tries to read an SSH key path. + +BehaviorLock reports the shell launch and credential path read as new observations. It does not decide why they happened. A maintainer reviews the evidence and decides whether the change is expected. + +## Status at a glance + +| Question | Current answer | +| --- | --- | +| Maturity | Public experiment, `0.1.0-dev` | +| Package ecosystem | Public npm registry packages | +| Version input | Exact semantic versions only | +| Observed environment | Linux container install lifecycle | +| CLI build and unit tests | Linux and macOS | +| Full Docker integration | GitHub hosted Linux runner | +| Native Windows or macOS tracing | Not supported | +| Profile authenticity | Unsigned, not attested | +| Tagged release | None | + +## What it observes + +The current parser records a bounded subset of: + +1. File reads, writes, creation, deletion, renaming, and permission changes +2. Executable launches and up to 32 visible arguments +3. Network connection attempts +4. Whether an observed call succeeded, failed, or was blocked + +The capture path exercises npm `preinstall`, `install`, and `postinstall` scripts through `npm rebuild`. It does not observe normal application runtime behavior. + +## How it works + +```text +exact npm version + | + v +prepare without lifecycle scripts + | + v +resolve immutable package filesystem + | + v +run lifecycle offline under strace + | + v +validate and normalize a profile + | + v +compare two compatible profiles + | + v +JSON, text, or Markdown report +``` -1. Public npm registry packages -2. Exact semantic versions -3. Linux containers -4. npm install lifecycle scripts -5. Offline script execution -6. JSON, text, and Markdown diffs +Preparation and execution are separate. Preparation needs registry access and runs with lifecycle scripts disabled. Execution starts from the prepared filesystem, has no network, uses a read only root filesystem, receives no host mounts or inherited credentials, and runs package code as uid `65532` with zero effective capabilities. -Tags, ranges, Git dependencies, local paths, private registries, Windows, macOS tracing, runtime monitoring, and malware classification are outside this release. +The preparation network is still a risk. Package metadata and transitive dependency metadata can influence what npm fetches. Use capture only on a disposable runner that cannot reach sensitive private networks or cloud metadata. -## Quick start +## Try the comparison without Docker -Go 1.23 or newer is enough for profile parsing and comparison. +Go 1.23 or newer is required. ```bash -go build -o bin/behaviorlock ./cmd/behaviorlock +go build -trimpath -o bin/behaviorlock ./cmd/behaviorlock bin/behaviorlock profile \ --package example@1.0.0 \ @@ -51,70 +93,142 @@ bin/behaviorlock compare \ --output behaviorlock.report.md ``` -The comparison exits with `1` when an added observation reaches the configured `--fail-on` threshold. The default threshold is `high`. - -The sample traces are inert fixtures. `profile --trace` marks output as `external-unverified`, and comparison refuses those profiles unless `--allow-external` is present. External traces do not attest network isolation, sandboxing, or provenance. - -All profile files are unsigned. Their provenance fields are claims, not cryptographic attestations, and can be edited. For policy enforcement, capture both versions inside the same trusted CI job and protect that workflow. Never accept a profile supplied or modified by an untrusted pull request as authoritative. - -> [!CAUTION] -> Profiles and reports can contain sensitive paths and package controlled strings. Review them before uploading, attaching them to an issue, or committing them. BehaviorLock never captures file contents, but paths alone can still disclose private information. +These traces are inert fixtures. Profiles created with `profile --trace` are marked `external-unverified`. Comparison rejects them unless `--allow-external` is present because their capture conditions and provenance cannot be verified. -## Experimental capture +## Capture a public npm package -Docker capture requires the repository runner image. +Docker is required. Build the pinned runner image from this repository first. ```bash make runner +make build bin/behaviorlock doctor bin/behaviorlock capture \ --experimental \ - --package example@1.0.0 \ + --package is-number@7.0.0 \ --timeout 2m \ - --output example.profile.json + --output is-number.profile.json ``` -The explicit `--experimental` flag is intentional. Acquisition runs as a nonroot user in a disposable container with lifecycle scripts disabled. The resolved lockfile digest is recorded, then the filesystem is committed to a temporary image. Lifecycle execution runs offline with a read only root filesystem, no host mounts, no inherited credentials, and bounded runtime resources. +`--experimental` is mandatory. The command records the exact runner image ID, architecture, Node version, npm version, `strace` version, package registry integrity, and dependency lock digest. Docker execution uses immutable image IDs after resolution so a mutable local tag cannot silently change the captured environment. + +Do not capture an unknown package on a machine that contains valuable data, credentials, trusted workloads, or access to private infrastructure. + +## Compare two captured versions -The trace supervisor and `strace` run under a different identity from package scripts. Trace files live in a root owned temporary filesystem that the package UID cannot read or modify. After dropping all capabilities, the container adds only `SETUID`, `SETGID`, and `SYS_PTRACE` for the supervisor to change identity and trace inside the container PID namespace. The package runs as uid `65532` with zero effective capabilities. +Both profiles must describe the same package and use the same runner image ID, architecture, Node version, npm version, `strace` version, network mode, sandbox profile, and coverage scope. + +```bash +bin/behaviorlock compare \ + --baseline package-1.0.0.profile.json \ + --candidate package-1.1.0.profile.json \ + --fail-on high \ + --format markdown \ + --output behaviorlock.report.md +``` -The implementation rejects an empty trace, missing start or end sentinel, timeout, truncated stream, malformed completion marker, parser error, or tracer process failure. Those conditions return an incomplete result and exit code `2`. This removes known false pass paths, but it is not a complete hostile code guarantee. +The default threshold is `high`. Exit code `1` means an added observation reached the selected threshold. It does not mean the package is malicious. -## What a report means +## Reading a report -BehaviorLock uses four observation states: +| Rule | Level | Meaning | +| --- | --- | --- | +| `BL100` | Critical | New access to a common credential or secret path | +| `BL200` | High | New network connection attempt during offline execution | +| `BL300` | High | New shell, downloader, or remote access process | +| `BL301` | Medium | New executable process | +| `BL400` | High | New mutation outside disposable work and temporary roots | +| `BL401` | Medium | New mutation inside a disposable writable root | +| `BL402` | Medium | New deletion or permission change | +| `BL500` | Low | New file read or metadata inspection | -1. `success` -2. `blocked` -3. `failed` -4. `unknown` +A `pass` verdict means no added observation reached the comparison rule. It does not authenticate the input profiles, establish full coverage, or prove safety. -Added behavior receives a transparent review rule. For example, `BL100` marks a new access to a common credential path and `BL200` marks a network attempt during an offline run. +## Command reference -These rules describe what the supplied profiles record. `pass` means only that no added observation reached the chosen comparison rule. It does not authenticate the profiles, infer intent, establish full coverage, or label a package as safe or malicious. +```text +behaviorlock doctor +behaviorlock capture --experimental --package name@1.2.3 --output profile.json +behaviorlock profile --package name@1.2.3 --trace raw.strace --output profile.json +behaviorlock compare --baseline old.json --candidate new.json --output report.json +behaviorlock validate --profile profile.json +behaviorlock version +``` + +Exit codes: + +1. `0` means the command completed and the comparison threshold was not reached. +2. `1` means a comparison reached the selected review threshold. +3. `2` means invalid input, incomplete evidence, sandbox failure, or another runtime error. + +## Platform support + +The Go parser and comparison code build and run on Linux and macOS. The capture backend observes Linux behavior because it depends on Linux containers, Linux permissions, and `strace`. + +Docker Desktop may allow a macOS or Windows host to operate a Linux container, but that still produces a Linux profile. Native Windows and native macOS behavior are outside this version, and Windows is not yet part of the CI build matrix. + +See [platform support](docs/PLATFORM_SUPPORT.md) for the exact distinction between host compatibility and observed target behavior. ## Security boundary -Containers share the host kernel. Package code can detect Docker, `strace`, missing network access, timing changes, and fake credentials. It can stay dormant or behave differently elsewhere. `strace` cannot observe ordinary in process environment variable reads. +The capture backend uses defense in depth: + +1. Strict package input validation before Docker runs +2. Docker argument arrays instead of host shell interpolation +3. No host mounts, Docker socket, inherited home directory, inherited credentials, or Docker client proxy variables +4. Offline lifecycle execution and a read only root filesystem +5. Bounded memory, CPU, processes, descriptors, temporary storage, output, and wall clock time +6. A root owned trace directory that package code cannot read or modify +7. Immutable Docker content IDs for the runner and prepared package filesystem +8. Required trace sentinels, completion evidence, and an empty tracer diagnostic channel + +Containers still share a kernel. Package code can detect tracing, stay dormant, exploit a runtime vulnerability, or behave differently outside the harness. Profiles are unsigned JSON. `validate` checks structure and internal consistency, not authenticity. + +Read [the threat model](docs/THREAT_MODEL.md) and [the limitations](docs/LIMITATIONS.md) before using capture as part of a security decision. + +## Documentation + +1. [User guide](docs/USER_GUIDE.md) explains the tool without requiring security expertise. +2. [Technical reference](docs/TECHNICAL_REFERENCE.md) documents the pipeline, data model, comparability rules, and failure behavior. +3. [Platform support](docs/PLATFORM_SUPPORT.md) describes Linux, macOS, and Windows support. +4. [Security audit](docs/SECURITY_AUDIT.md) records the latest review, fixes, scan evidence, and remaining risks. +5. [Architecture](docs/ARCHITECTURE.md) describes component boundaries. +6. [Threat model](docs/THREAT_MODEL.md) lists assets, hostile inputs, controls, and residual risk. +7. [Limitations](docs/LIMITATIONS.md) states what BehaviorLock cannot observe or prove. +8. [Roadmap](ROADMAP.md) contains the release gates. +9. [Security policy](SECURITY.md) explains private vulnerability reporting. + +## Development + +```bash +make check +make build +``` + +Docker integration runs separately: + +```bash +make integration +``` -Read [the threat model](docs/THREAT_MODEL.md) and [the limitations](docs/LIMITATIONS.md) before using capture. +The protected `ci-required` job runs race enabled tests, shell checks, schema checks, vulnerability scanning, DCO verification, and the hardened Docker integration. CodeQL and scheduled parser fuzzing run in separate workflows. -## Repository status +## Project status -The parser and comparison core are tested locally. The capture backend remains experimental until the adversarial release gates in [the roadmap](ROADMAP.md) pass on GitHub hosted Linux runners. No tagged executable release is promised yet. +BehaviorLock is a public experiment with no tagged release. The parser and comparison core are usable now. The capture backend remains experimental until every adversarial gate in [ROADMAP.md](ROADMAP.md) passes, trusted profiles have verifiable provenance, and the acquisition network boundary is stronger. -## Contributing +Profiles and reports can retain sensitive paths and package controlled strings. Review every artifact before attaching it to an issue, publishing it, or committing it. -Outside contributions are welcome. Significant behavior, schema, sandbox, or policy changes begin with an issue. Every commit must include a DCO signoff, and pull requests must pass the protected `ci-required` check. +## Contributing and security reports -Start with [CONTRIBUTING.md](CONTRIBUTING.md), [GOVERNANCE.md](GOVERNANCE.md), and [SECURITY.md](SECURITY.md). +Start with [CONTRIBUTING.md](CONTRIBUTING.md). Human commits require Developer Certificate of Origin 1.1 signoff. Security vulnerabilities belong in [GitHub private vulnerability reporting](https://github.com/kiranmagic7/behaviorlock/security/advisories/new), not public issues. ## Name and prior work -This repository uses the owner qualified name `kiranmagic7/behaviorlock`. It is independent of the earlier `christian140903-sudo/behaviorlock` project, which checks AI agent compatibility. The two projects have different purposes and no affiliation. +This repository uses the owner qualified name `kiranmagic7/behaviorlock`. It is independent of the earlier `christian140903-sudo/behaviorlock` project, which checks AI agent compatibility. The projects have different purposes and no affiliation. -BehaviorLock also does not claim ownership of the phrase “Bill of Behavior” and does not present itself as a new standard. [ORIGINS.md](docs/ORIGINS.md) records adjacent projects and the boundaries of this implementation. +BehaviorLock does not claim ownership of the phrase "Bill of Behavior" and does not present itself as a standard. [ORIGINS.md](docs/ORIGINS.md) records adjacent projects and the boundaries of this implementation. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 4949d56..6f5b66f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,15 +15,16 @@ Every item below must pass on a GitHub hosted Linux runner: 2. Package input cannot change the image, entrypoint, mounts, network, environment, or Docker flags. 3. Host environment, home, npm configuration, SSH material, Git configuration, cloud credentials, and Docker socket never appear inside the container or report. 4. Root filesystem writes fail and work directory writes succeed. -5. TCP, UDP, DNS, private address, host gateway, and cloud metadata attempts fail. -6. Child, grandchild, native executable, and shell activity remain observable. -7. Tracer death terminates tracees and produces `trace_incomplete`. -8. Fake syscall lines, terminal control characters, and GitHub workflow commands cannot enter the trace channel. -9. Process, memory, descriptor, file, output, syscall, and timeout exhaustion stop within limits and leave no container or image. -10. Unsupported tracing fails closed without privileged mode, disabled seccomp, host namespaces, or broad capabilities. -11. Ten repeated trusted fixture runs produce the same normalized behavior set and stable digest. -12. Every added report item points to retained raw trace evidence. -13. Trusted CI profiles carry verifiable provenance or an artifact attestation before they are used as cross-workflow policy inputs. +5. Lifecycle TCP, UDP, DNS, private address, host gateway, and cloud metadata attempts fail. +6. Acquisition uses allowlisted public registry egress or an equivalent disposable host boundary with no sensitive routes. +7. Child, grandchild, native executable, and shell activity remain observable. +8. Tracer death and tracer diagnostics terminate tracees and produce `trace_incomplete`. +9. Fake syscall lines, terminal control characters, and GitHub workflow commands cannot enter the trace channel. +10. Process, memory, descriptor, file, output, syscall, and timeout exhaustion stop within limits and leave no container or image. +11. Unsupported tracing fails closed without privileged mode, disabled seccomp, host namespaces, or broad capabilities. +12. Ten repeated trusted fixture runs produce the same normalized behavior set and stable digest. +13. Every added report item points to retained raw trace evidence. +14. Trusted CI profiles carry verifiable provenance or an artifact attestation before they are used as cross-workflow policy inputs. ## Later options diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 56c6cac..c823e97 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,19 +8,21 @@ The first release accepts only an npm registry name followed by an exact semanti Docker commands are created as argument arrays. User input is never placed inside a host shell command, container name, image name, mount, environment variable name, or Docker option. +The local runner tag is resolved to one validated Docker content ID before capture. Metadata inspection and preparation use that immutable ID. The committed package filesystem is also executed by the content ID returned from `docker commit`, not by its temporary tag. + ## Observation Capture has two disposable container phases. -The preparation phase installs an exact top level package version with lifecycle scripts disabled and records the generated dependency lock digest. It runs as uid `65532` and receives no host mounts, home directory, npm configuration, Git configuration, SSH material, cloud credentials, repository token, or Docker socket. +The preparation phase installs an exact top level package version with lifecycle scripts disabled and records the generated dependency lock digest. It runs as uid `65532` and receives no host mounts, home directory, npm configuration, Git configuration, SSH material, cloud credentials, repository token, or Docker socket. Standard uppercase and lowercase proxy variables are explicitly empty, which prevents Docker client proxy settings from being injected. Preparation still has direct registry network access, so package and transitive dependency metadata can influence outbound fetches. It belongs on a disposable runner with no route to sensitive private services. The execution phase starts from the prepared filesystem. Networking is disabled. The root filesystem is read only and writable locations are bounded temporary filesystems. A root supervisor owns the trace channel while the package command runs as uid `65532`. After dropping all capabilities, the container adds only `SETUID`, `SETGID`, and `SYS_PTRACE` so the supervisor can perform the identity transition and trace inside the container PID namespace. The package process has zero effective capabilities. Docker's default seccomp policy remains intact. -`strace` writes into a root owned mode `0700` temporary filesystem that package code cannot access. It follows a selected set of file, process, and network syscalls. Package output is separated from the trace envelope. Root owned start and end sentinel reads and a completion footer establish basic channel integrity. Missing or malformed completion evidence makes the profile incomplete. +`strace` writes into a root owned mode `0700` temporary filesystem that package code cannot access. It follows a selected set of file, process, and network syscalls. Package output is separated from the trace envelope. Root owned start and end sentinel reads, an empty tracer diagnostic channel, and a completion footer establish basic channel integrity. Missing evidence, tracer diagnostics, timeout, truncation, or malformed completion evidence makes the profile incomplete. ## Normalization -The parser has byte, line, and behavior limits. It rejects invalid UTF 8 and unfinished syscalls. It normalizes only known disposable paths and process identifiers: +The parser has byte, line, and behavior limits. It rejects invalid UTF 8 and unfinished syscalls. It normalizes only known disposable roots at exact path boundaries and selected process identifiers: 1. `/work` becomes `$WORK` 2. `/home/scanner` becomes `$HOME` diff --git a/docs/DECISION_RECORD.md b/docs/DECISION_RECORD.md index e6113a1..f098fbb 100644 --- a/docs/DECISION_RECORD.md +++ b/docs/DECISION_RECORD.md @@ -20,7 +20,7 @@ Known scanners already cover vulnerabilities, metadata, and static patterns. Ins ## Tradeoffs -The narrow scope produces less dramatic claims and better testability. Offline execution improves containment and reduces coverage. Docker improves accessibility but cannot provide a hostile code isolation guarantee. +The narrow scope produces less dramatic claims and better testability. Offline execution improves containment and reduces coverage. Docker improves accessibility but cannot provide a hostile code isolation guarantee. Acquisition network isolation is weaker than lifecycle isolation and must remain visible as a release blocker. ## Risk and reversibility diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 2dc8721..3cdede5 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -14,10 +14,11 @@ BehaviorLock observes one narrow execution path. The following limits are part o 10. Failed access attempts remain observations and may create false positives. 11. A comparable profile depends on the same runner image, architecture, Node version, npm version, and harness. The generated dependency lock digest records, but does not eliminate, dependency graph variation. 12. The Debian base image is pinned, but live operating system packages installed during a runner build are not snapshot pinned. Use the exact runner image ID for comparison. -13. The acquisition phase has a wall clock limit and memory controls but no portable Docker overlay disk quota. A disposable runner remains necessary. +13. The acquisition phase has a wall clock limit and memory controls but no portable Docker overlay disk quota. It also has registry network access, and dependency metadata can influence fetch destinations. Use a disposable runner with no route to private networks, cloud metadata, or trusted services. 14. Raw traces are hashed but not retained by default. Content derived evidence identifiers are stable references to normalized records, not a substitute for raw forensic evidence. 15. An incomplete trace is an error, but a complete trace still cannot prove full coverage or prevent evasive dormant behavior. 16. Profiles can retain sensitive paths and package controlled strings. Review artifacts before sharing them. 17. Profiles are unsigned JSON. Validation checks structure and internal consistency, not authenticity. Enforcement workflows must generate profiles in a trusted job rather than accepting contributor supplied artifacts. +18. The Go CLI is tested on Linux and macOS, but full capture is verified only on GitHub hosted Linux. Native Windows and macOS tracing are not implemented. BehaviorLock reports observations and changes. It does not report that a package is safe, clean, benign, malicious, or free of vulnerabilities. diff --git a/docs/PLATFORM_SUPPORT.md b/docs/PLATFORM_SUPPORT.md new file mode 100644 index 0000000..fec5b44 --- /dev/null +++ b/docs/PLATFORM_SUPPORT.md @@ -0,0 +1,47 @@ +# Platform support + +BehaviorLock has two platform questions: where the CLI can run, and which operating system behavior the capture represents. They are not the same. + +## Current support matrix + +| Capability | Linux | macOS | Windows | +| --- | --- | --- | --- | +| Build the Go CLI | Verified | Verified | Not yet tested in CI | +| Parse an existing Linux `strace` file | Expected | Expected | Not yet tested in CI | +| Compare compatible profile JSON | Expected | Expected | Not yet tested in CI | +| Run the full capture integration | Verified on GitHub hosted Linux | Not verified | Not verified | +| Observe native operating system behavior | Linux only | No | No | + +## Linux + +Linux is the observed target platform for `0.1.0-dev`. The runner depends on Linux containers, Linux permissions, Linux capabilities, `/proc`, and `strace`. + +The complete Docker integration runs on GitHub hosted Ubuntu. It checks the package uid, effective capabilities, trace isolation, blocked network access, canary path visibility, immutable capture evidence, and cleanup. + +## macOS + +The Go CLI builds and its unit tests run on GitHub hosted macOS. + +The full Docker capture has not been verified on macOS. Docker Desktop could operate the Linux runner inside its virtual machine, but the result would describe Linux container behavior. It would not show native macOS file, process, or network events. + +## Windows + +Windows is not in the current CI matrix. The code may compile because the parser and comparison core use portable Go, but this has not been established by hosted tests. + +Docker Desktop can run Linux containers on some Windows configurations. Even if capture works there, the resulting profile would describe Linux behavior rather than native Windows behavior. + +## What native support would require + +Native support needs a separate capture backend for each operating system. + +1. Linux can continue using `strace`, with eBPF as a possible later option. +2. macOS would need an authorized event source such as Endpoint Security or another supported tracing mechanism. +3. Windows would need Windows event telemetry such as ETW plus a separate containment design. + +The normalized profile and comparison model could remain shared, but every backend would need its own threat model, coverage declaration, comparability rules, tests, and release gate. + +## Accurate public description + +The current accurate description is: + +> BehaviorLock compares observed npm install lifecycle behavior in Linux containers. Its parser and comparison core are portable, but native Windows and macOS tracing are not implemented. diff --git a/docs/SECURITY_AUDIT.md b/docs/SECURITY_AUDIT.md new file mode 100644 index 0000000..ccbf3f8 --- /dev/null +++ b/docs/SECURITY_AUDIT.md @@ -0,0 +1,105 @@ +# Security audit + +## Review record + +| Field | Value | +| --- | --- | +| Review date | 2026-08-08 | +| Reviewed base | `2594d5c32961cb1b55508effbe1bf5e9225e9557` | +| Scope | Go CLI, Docker orchestration, runner scripts, parser, model validation, report rendering, schemas, and GitHub Actions | +| Release status | Experimental, no tag | +| Overall decision | Suitable for continued public experimentation after the fixes in this change. Not approved as a malware sandbox or stable security product. | + +## Method + +The review combined: + +1. Manual data flow and trust boundary review +2. Docker argument and lifecycle analysis +3. Adversarial reasoning about package controlled input +4. `gosec` across all Go packages +5. `govulncheck` at symbol level +6. `go vet`, race enabled tests, and Staticcheck +7. Bounded fuzzing of package specification and trace parsers +8. ShellCheck and shell syntax checks +9. Actionlint and manual GitHub Actions permission review +10. GitHub Dependabot, code scanning, and secret scanning alert review + +No live malicious package was executed. Adversarial behavior uses inert local fixtures only. + +## Fixed findings + +### High: Docker client proxy configuration could enter containers + +Docker can automatically populate proxy environment variables from the operator's client configuration. Proxy values may contain internal addresses or credentials. The previous Docker argument allowlist did not explicitly override them, so package code could potentially read values that the tool claimed were not inherited. + +The fix explicitly sets uppercase and lowercase HTTP, HTTPS, all proxy, and no proxy variables to empty in preparation, execution, and metadata containers. Unit tests inspect every Docker argument vector, and the hosted package fixture fails if any proxy value is visible. + +### Medium: mutable runner reference after evidence collection + +The capture path inspected the local runner tag and recorded its image ID, but later commands still used the tag. Another local process with Docker authority could retag it between inspection and execution. A profile could then name one runner ID while execution used another image. + +The fix resolves the tag once, validates the returned SHA 256 content ID, and uses that immutable ID for version inspection and preparation. The committed package filesystem is also executed by its returned content ID. Regression tests reject mutable references and invalid commit output. + +### Medium: tracer diagnostics could look like a package failure + +The runner relied on the `strace` exit code, which normally mirrors the traced command. A tracer failure can also return a nonzero code. Without checking the root owned diagnostic channel, some tracer failures could be reported as an ordinary lifecycle command failure instead of incomplete evidence. + +The fix requires the `strace` diagnostic file to remain empty. Any diagnostic now fails capture before a trusted footer is emitted. Hosted integration builds a fake tracer that emits valid looking sentinel lines plus a diagnostic and verifies fail closed behavior. + +### Low: path normalization rewrote lookalike roots + +Normalization replaced `/work` and `/home/scanner` wherever those strings appeared. Paths such as `/workspace/file` could be rewritten even though they were outside the disposable root. + +The fix replaces roots only when the path is exactly the root or begins with the root followed by `/`. Regression tests cover both genuine and lookalike roots. + +### Low: runtime profile validation was weaker than the JSON schema + +The Go validator accepted any string beginning with `sha512-` as registry integrity and did not fully bound coverage arrays or behavior counts. It also did not require a captured runner image ID to be a SHA 256 digest. + +The fix validates the decoded SHA 512 length, runner image content ID, lifecycle and completeness values, coverage text, item counts, and normalized profile state. The JSON schema now carries the same count and coverage limits. + +## Open design risks + +### Acquisition has broad outbound network access + +Preparation needs npm registry access. Lifecycle scripts are disabled, but package and transitive dependency metadata can still influence what npm fetches. The preparation container can potentially reach destinations beyond the public npm registry, including addresses reachable from the Docker bridge. + +This remains a release blocker. Until an allowlisted acquisition proxy, equivalent egress control, or a stronger disposable virtual machine design exists, capture must run on an ephemeral host with no access to private networks, cloud metadata, trusted services, or credentials. + +### Containers share the host kernel + +The package process has no host mounts, no Docker socket, no network during lifecycle execution, and zero effective capabilities. It still shares a kernel with the Docker host or Docker Desktop virtual machine. A kernel or container runtime exploit is outside the protection offered by this harness. + +### Profiles are unsigned + +Anyone can edit JSON provenance fields. Structural validation cannot establish authenticity. Enforcement workflows must create both profiles in a protected trusted job and must not accept contributor supplied profiles as policy evidence. + +### Observation is incomplete + +The parser covers selected system calls and one install lifecycle path. It does not capture file contents, ordinary in process environment reads, all network operations, every filesystem mutation, delayed behavior, or normal package runtime. + +## Tool results + +| Check | Result during this review | +| --- | --- | +| `gosec` | 0 findings across 7 Go source packages | +| `govulncheck` | 0 reachable vulnerabilities | +| Staticcheck | 0 findings | +| GitHub Dependabot alerts | 0 open alerts | +| GitHub code scanning alerts | 0 open alerts | +| GitHub secret scanning alerts | 0 open alerts | +| Package specification fuzzing | No failure during the bounded local run | +| Trace parser fuzzing | No failure during the bounded local run | +| Existing hosted CodeQL | Green on reviewed base | +| Existing hosted Docker integration | Green on reviewed base | + +The local Go toolchain reported vulnerabilities in standard library or imported code paths that BehaviorLock does not call. `govulncheck` found zero symbol reachable vulnerabilities in BehaviorLock. Future distributed binaries must be built with a fully patched Go toolchain. + +## Release decision + +This review does not open the `v0.1.0` release gate. The project may continue as an explicitly experimental source repository. A tagged release still requires every adversarial item in [ROADMAP.md](../ROADMAP.md), stronger acquisition isolation, and verifiable profile provenance. + +## Reporting a new issue + +Do not publish a vulnerability in a public issue. Use [GitHub private vulnerability reporting](https://github.com/kiranmagic7/behaviorlock/security/advisories/new) and follow [SECURITY.md](../SECURITY.md). diff --git a/docs/TECHNICAL_REFERENCE.md b/docs/TECHNICAL_REFERENCE.md new file mode 100644 index 0000000..96e70ac --- /dev/null +++ b/docs/TECHNICAL_REFERENCE.md @@ -0,0 +1,198 @@ +# Technical reference + +## Scope + +BehaviorLock `0.1.0-dev` compares selected Linux system calls observed while npm install lifecycle scripts execute for two exact versions of the same public registry package. + +The system has four main boundaries: + +1. Package specification validation +2. Acquisition and preparation +3. Offline execution and tracing +4. Normalization, validation, and comparison + +## Components + +| Component | Responsibility | +| --- | --- | +| `internal/npm` | Parse exact npm package specifications and generate package URLs | +| `internal/capture` | Construct bounded Docker argument arrays and orchestrate capture | +| `runner` | Prepare package filesystems and run lifecycle scripts under `strace` | +| `internal/trace` | Parse bounded trace input into normalized behaviors | +| `internal/model` | Validate profiles, normalize behavior, and compute stable digests | +| `internal/compare` | Check profile compatibility, calculate changes, and assign review rules | +| `internal/cli` | Expose commands, exit codes, and report formats | +| `schemas` | Publish versioned JSON contracts for profiles and diffs | + +## Input contract + +Capture accepts a public npm package name followed by one exact semantic version. + +Accepted examples: + +```text +lodash@4.17.21 +@scope/package@1.2.3 +package@1.0.0-rc.1 +``` + +Ranges, tags, aliases, URLs, Git references, local paths, whitespace, control characters, Unicode lookalikes, and leading option syntax are rejected before Docker starts. + +## Capture phases + +### Runner resolution + +The local tag `behaviorlock-runner:dev` is inspected once to obtain its Docker content ID and architecture. All later metadata and preparation commands use the immutable content ID, not the mutable tag. The profile records both the human readable tag and the content ID. + +### Preparation + +Preparation runs as uid `65532` with lifecycle scripts disabled. It receives no host mounts, Docker socket, home directory, npm configuration, Git configuration, SSH data, cloud credentials, or repository tokens. Uppercase and lowercase HTTP, HTTPS, all proxy, and no proxy variables are explicitly set to empty so Docker client proxy configuration cannot enter the container. + +It has registry network access. This is a weaker boundary than execution. Dependency metadata can influence outbound acquisition requests, and the phase has no portable overlay disk quota. It belongs on a disposable runner with no route to sensitive private services. + +Preparation records: + +1. Top level npm registry integrity +2. SHA 256 of the generated dependency lockfile +3. Runner image content ID and architecture +4. Node, npm, and `strace` versions + +The stopped preparation container is committed to a random temporary tag. Docker returns the committed content ID, and execution uses that immutable ID. + +### Execution + +Execution uses: + +1. Docker network mode `none` +2. A read only root filesystem +3. No host mounts or host namespaces +4. Bounded tmpfs mounts for work, temporary data, home, and trace storage +5. Memory, CPU, process, descriptor, shared memory, and wall clock limits +6. Docker's default seccomp policy +7. `no-new-privileges` + +The supervisor begins as root and retains only `SETUID`, `SETGID`, and `SYS_PTRACE` after all capabilities are dropped. Package code runs as uid `65532` with zero effective capabilities. + +The trace directory is owned by root with mode `0700`. Package code cannot read, erase, or replace trace files. + +### Completion evidence + +A trusted envelope requires: + +1. A versioned trace header +2. A successful read of the start sentinel +3. At least one recognized event +4. A successful read of the end sentinel +5. A valid completion footer and child exit code +6. No `strace` diagnostics +7. No timeout or output truncation + +Any missing condition produces an incomplete result and exit code `2`. + +## Normalized behavior + +Each behavior contains: + +| Field | Meaning | +| --- | --- | +| `type` | File, process, or network behavior category | +| `operation` | Normalized action such as read, write, or exec | +| `target` | Normalized path, executable, or endpoint | +| `arguments` | Bounded visible process arguments | +| `outcome` | `success`, `blocked`, `failed`, or `unknown` | +| `errno` | Visible Linux error name when present | +| `sensitive` | Whether the target matches a common credential path | +| `count` | Number of equivalent raw observations | +| `evidence` | Content derived event identifier | +| `sourceSyscall` | Original syscall family | + +Disposable roots are normalized only at path boundaries: + +```text +/work/file becomes $WORK/file +/workspace/file stays /workspace/file +/home/scanner/.npmrc becomes $HOME/.npmrc +/home/scanner-backup/file stays /home/scanner-backup/file +``` + +## Profile validation + +`validate` checks: + +1. Schema and kind versions +2. Exact package name, version, and package URL agreement +3. Bounded UTF 8 text without control characters +4. Valid SHA 256 content IDs and digests +5. Valid SHA 512 npm integrity evidence +6. Consistent capture mode, coverage, lifecycle, and result state +7. Behavior type, argument, count, evidence, and outcome limits +8. No trailing JSON values or unknown fields + +Validation is structural. Profiles have `attestation: none`, so successful validation does not establish who created a profile or whether its provenance fields are true. + +## Stable digest + +The stable profile digest includes subject identity, tool identity, capture environment, result state, and the normalized behavior set. + +It excludes duration, raw trace hash, repeated event counts, process IDs, unstable temporary paths, and raw trace line numbers. This makes repeated captures comparable while preserving fields that can change the meaning of a result. + +## Comparison contract + +Profiles must: + +1. Be structurally valid and complete +2. Describe the same npm package +3. Use the same trace integrity mode +4. Use the same runner image ID and architecture +5. Use the same Node, npm, and `strace` versions +6. Use the same network mode, sandbox profile, and coverage scope + +External traces require explicit `--allow-external` acknowledgement. + +The comparator calculates added and removed behavior keys. Added behavior is classified by deterministic rules in `internal/compare`. + +## CLI commands + +### `doctor` + +Checks that Docker is available and the local runner tag resolves to a valid content ID. + +### `capture` + +Acquires an exact public npm version and creates a trusted harness profile. It requires `--experimental`. + +### `profile` + +Converts caller supplied raw `strace` input into an `external-unverified` profile. + +### `compare` + +Compares two compatible profiles and writes JSON, text, or Markdown. + +### `validate` + +Checks one profile and prints its stable digest. It explicitly states that authenticity was not verified. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Operation completed and the comparison threshold was not reached | +| `1` | Added behavior reached the selected comparison threshold | +| `2` | Input, evidence, sandbox, parser, or runtime failure | + +## Resource limits + +The trace parser accepts at most 64 MiB of raw trace data, 256 KiB per line, and 250,000 recognized behaviors. Profile JSON is limited to 32 MiB. Docker adds separate process, memory, CPU, file descriptor, shared memory, tmpfs, output, and wall clock limits. + +## Reproducibility + +Comparable profiles require the same execution environment, but identical environments do not guarantee identical package behavior. Packages can use randomness, time, architecture checks, dependency variation, or tracing detection. + +The dependency lock digest records graph variation. It does not prevent it. Repeat important captures and inspect disagreement rather than automatically approving a new baseline. + +## Development verification + +`make check` runs formatting, `go vet`, race enabled tests, shell syntax, ShellCheck, Actionlint, and JSON checks. + +Hosted workflows add vulnerability scanning, CodeQL, scheduled fuzzing, DCO enforcement, and hardened Docker integration. The integration includes an inert adversarial fixture and a simulated tracer diagnostic that must fail closed. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index e9fca5d..bac5b98 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -25,9 +25,11 @@ The important boundaries are: Only exact registry versions are accepted. Docker is invoked with argument arrays. Package input cannot alter images, entrypoints, mounts, environment variables, container names, or Docker flags. +Capture resolves mutable local image tags to validated SHA 256 content IDs before use. Preparation and execution use those immutable IDs so a concurrent local retag cannot change the environment after profile evidence is collected. + ### Credential exposure -The container environment is an allowlist. Host environment variables and user configuration are not inherited. Host home, repository, Docker socket, SSH files, npm configuration, and cloud credentials are never mounted. +The container environment is an allowlist. Host environment variables and user configuration are not inherited. Host home, repository, Docker socket, SSH files, npm configuration, and cloud credentials are never mounted. Uppercase and lowercase proxy variables are explicitly set to empty because Docker client configuration can otherwise inject proxy values into new containers. Fake credential files are placed inside the disposable container so access attempts can be observed without exposing real secrets. @@ -35,7 +37,7 @@ Fake credential files are placed inside the disposable container so access attem Lifecycle execution uses Docker network mode `none`. Connect attempts can still appear in `strace`, but they cannot reach an external destination through the container network. -The preparation phase has registry access while scripts are disabled. This reduces risk but does not make acquisition harmless. A vulnerability in npm, the container runtime, or the host kernel remains possible. +The preparation phase has registry access while scripts are disabled. This reduces risk but does not make acquisition harmless. Package and transitive dependency metadata can influence npm fetch destinations. A disposable runner must not have routes to private services, cloud metadata, or trusted local infrastructure. An allowlisted acquisition network remains a release blocker. ### Host modification @@ -47,7 +49,7 @@ Docker bounds memory, process count, CPU, file descriptors, shared memory, and r ### Trace tampering -The trace supervisor and `strace` run separately from the package uid. The supervisor retains `SYS_PTRACE` only inside the container PID namespace; the package runs as uid `65532` with zero effective capabilities. Root owned mode `0700` trace storage prevents the package from erasing or replacing raw trace files. Start and end sentinel reads, a nonempty recognized event set, a footer, and tracer exit status must all be present. A missing sentinel, tracer failure, timeout, parser error, or output limit yields an incomplete result. +The trace supervisor and `strace` run separately from the package uid. The supervisor retains `SYS_PTRACE` only inside the container PID namespace; the package runs as uid `65532` with zero effective capabilities. Root owned mode `0700` trace storage prevents the package from erasing or replacing raw trace files. Start and end sentinel reads, a nonempty recognized event set, an empty root owned tracer diagnostic file, a footer, and tracer exit status must all be present. A missing sentinel, tracer diagnostic, timeout, parser error, or output limit yields an incomplete result. Package code can still detect tracing, alter its own behavior, attack the shared kernel, or exploit a tracer or runtime vulnerability. A tagged executable release remains blocked until broader adversarial tests show that package output cannot enter the trace channel and tracees terminate when the tracer dies. @@ -59,7 +61,7 @@ Profile JSON is not signed. A contributor can forge provenance fields in a file. ## Residual risk -Containers are not virtual machines. Docker and `strace` do not contain every hostile package. Rootless Docker, user namespace remapping, Docker Desktop's virtual machine, or a disposable Linux virtual machine reduces risk. Unknown hostile packages should not run on a personal workstation. +Containers are not virtual machines. Docker and `strace` do not contain every hostile package. Rootless Docker, user namespace remapping, Docker Desktop's virtual machine, or a disposable Linux virtual machine reduces risk. Acquisition can still reach destinations available to the Docker bridge. Unknown hostile packages should not run on a personal workstation or a network trusted host. ## Security release gate diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..6afc97d --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,109 @@ +# User guide + +## What BehaviorLock does + +BehaviorLock compares what two versions of an npm package were observed doing while their install scripts ran in the same Linux harness. + +Think of it as a change detector. It does not ask whether opening a file or starting a process is good or bad. It shows what appeared in the newer version but not in the older one, then assigns a review level so the most sensitive changes are easier to find. + +## What question it answers + +BehaviorLock can help answer: + +> What did this package version begin doing during installation that the previous version did not do? + +It cannot answer: + +> Is this package safe? + +No single execution can answer that. Code can stay dormant, detect the test environment, run only for certain users, or wait until normal application runtime. + +## Who it is for + +BehaviorLock is intended for: + +1. Package maintainers reviewing a release +2. Security engineers investigating dependency changes +3. CI owners who want a review signal before accepting an update +4. Researchers building better software supply chain evidence + +It is not yet intended for people who need a polished desktop application, a hosted dashboard, or a one click malware verdict. + +## What the report contains + +A report lists behavior that was added or removed between two profiles. + +Examples include: + +1. A new executable was started. +2. A new credential path was inspected. +3. A file was created outside the normal disposable work directory. +4. A network connection was attempted even though execution was offline. + +Each added behavior has a level: + +| Level | What it means | +| --- | --- | +| Critical | Review immediately. A common secret or credential path was accessed. | +| High | Review before accepting the update. The change includes a sensitive process, network attempt, or unexpected filesystem target. | +| Medium | Inspect the change. It may be normal build behavior, but it is new. | +| Low | Usually informational, such as a new file read. | + +The level describes the observation, not the author's intent. + +## What verdicts mean + +`pass` means no added observation reached the configured threshold. + +`review` means behavior changed, but the highest change remained below the failure level. + +`fail` means at least one added observation reached a high or critical review level. + +None of these verdicts proves that a package is benign or malicious. + +## A sensible review process + +When a report changes: + +1. Confirm that both profiles used the same runner and tool versions. +2. Read the highest level additions first. +3. Check whether the package release notes or source diff explain each addition. +4. Reproduce the capture on a disposable runner if the result is surprising. +5. Ask the package maintainer privately before making a public accusation. +6. Accept a new baseline only after a person understands the change. + +BehaviorLock should support a review decision, not replace one. + +## Safe use + +Use the fixture based quick start for learning. It does not execute downloaded package code. + +Real capture does execute package lifecycle code. Use an ephemeral GitHub hosted Linux runner or a disposable virtual machine with no valuable files, credentials, private network access, or cloud privileges. + +Do not run an unknown package on a personal workstation. Docker reduces exposure, but containers share a kernel and are not a complete hostile code boundary. + +## Privacy + +BehaviorLock does not capture file contents. It can still record sensitive paths, process arguments, package controlled strings, hostnames visible inside the container, and network destinations. + +Review profiles and reports before sharing them. Remove private paths, repository names, internal addresses, and any other information that should not be public. + +## Operating systems + +The current capture result describes Linux behavior. + +The CLI builds on Linux and macOS. Docker Desktop may let macOS or Windows operate a Linux container, but the resulting profile is still a Linux profile. Native Windows and native macOS tracing are not implemented. + +## Current maturity + +There is no tagged release. Installation requires building from source, and capture requires a locally built runner image. Profiles are unsigned, so policy jobs must generate their own profiles in a trusted workflow. + +This is useful for experiments and design partnerships. It is not yet a finished security product. + +## Where to go next + +1. Use the [README](../README.md) for commands. +2. Read [platform support](PLATFORM_SUPPORT.md) before planning cross platform use. +3. Read the [technical reference](TECHNICAL_REFERENCE.md) to understand profile compatibility. +4. Read the [threat model](THREAT_MODEL.md) before running real capture. +5. Use [GitHub Discussions](https://github.com/kiranmagic7/behaviorlock/discussions) for questions and design conversation. diff --git a/internal/capture/docker.go b/internal/capture/docker.go index 81fffa6..5f9f956 100644 --- a/internal/capture/docker.go +++ b/internal/capture/docker.go @@ -43,6 +43,11 @@ type RunnerMetadata struct { Strace string `json:"strace"` } +type imageInspect struct { + ID string `json:"Id"` + Architecture string `json:"Architecture"` +} + type DockerRunner struct { dockerPath string run func(context.Context, []string, int64, int64) (commandResult, error) @@ -73,8 +78,7 @@ func (runner *DockerRunner) Doctor(ctx context.Context) error { if result.ExitCode != 0 || strings.TrimSpace(string(result.Stdout)) == "" { return fmt.Errorf("docker daemon check failed: %s", safeDiagnostic(result.Stderr)) } - result, err = runner.run(ctx, []string{"image", "inspect", RunnerImage, "--format", "{{.Id}}"}, 64<<10, 64<<10) - if err != nil || result.ExitCode != 0 { + if _, _, err := runner.imageDetails(ctx, RunnerImage); err != nil { return fmt.Errorf("runner image %s is missing; run `make runner` from the repository", RunnerImage) } return nil @@ -109,7 +113,7 @@ func (runner *DockerRunner) Capture(ctx context.Context, spec npm.Spec, config C if err != nil { return timedOutProfile(profile, captureContext, err) } - runnerMetadata, err := runner.runnerMetadata(captureContext) + runnerMetadata, err := runner.runnerMetadata(captureContext, runnerImageID) if err != nil { return timedOutProfile(profile, captureContext, err) } @@ -119,7 +123,7 @@ func (runner *DockerRunner) Capture(ctx context.Context, spec npm.Spec, config C profile.Capture.NPMVersion = runnerMetadata.NPM profile.Capture.StraceVersion = runnerMetadata.Strace - prepareArgs := buildPrepareArgs(prepContainer, spec.String()) + prepareArgs := buildPrepareArgs(prepContainer, runnerImageID, spec.String()) created, err := runner.run(captureContext, prepareArgs, 64<<10, maxPreparationLog) if err != nil || created.ExitCode != 0 { return timedOutProfile(profile, captureContext, fmt.Errorf("create preparation container: %s", safeDiagnostic(created.Stderr))) @@ -138,10 +142,14 @@ func (runner *DockerRunner) Capture(ctx context.Context, spec npm.Spec, config C if err != nil || committed.ExitCode != 0 { return timedOutProfile(profile, captureContext, fmt.Errorf("commit disposable preparation image: %s", safeDiagnostic(committed.Stderr))) } + preparedImageID := strings.TrimSpace(string(committed.Stdout)) + if !validSHA256(preparedImageID) { + return profile, errors.New("committed preparation image did not resolve to a content ID") + } _, _ = runner.run(captureContext, []string{"rm", "--force", prepContainer}, 64<<10, 64<<10) started := time.Now() - traced, runErr := runner.run(captureContext, buildTraceArgs(traceContainer, temporaryImage, spec.String()), maxTraceStream, 1<<20) + traced, runErr := runner.run(captureContext, buildTraceArgs(traceContainer, preparedImageID, spec.String()), maxTraceStream, 1<<20) duration := time.Since(started) profile.Capture.DurationMillis = duration.Milliseconds() @@ -174,8 +182,8 @@ func (runner *DockerRunner) Capture(ctx context.Context, spec npm.Spec, config C return profile, nil } -func buildPrepareArgs(containerName, packageSpec string) []string { - return []string{ +func buildPrepareArgs(containerName, runnerImageID, packageSpec string) []string { + arguments := []string{ "create", "--name", containerName, "--network", "bridge", @@ -197,12 +205,13 @@ func buildPrepareArgs(containerName, packageSpec string) []string { "--env", "npm_config_audit=false", "--env", "npm_config_fund=false", "--env", "npm_config_update_notifier=false", - RunnerImage, "prepare", packageSpec, } + arguments = appendScrubbedProxyEnvironment(arguments) + return append(arguments, runnerImageID, "prepare", packageSpec) } func buildTraceArgs(containerName, image, packageSpec string) []string { - return []string{ + arguments := []string{ "run", "--name", containerName, "--network", "none", "--read-only", @@ -231,35 +240,46 @@ func buildTraceArgs(containerName, image, packageSpec string) []string { "--env", "npm_config_audit=false", "--env", "npm_config_fund=false", "--env", "npm_config_update_notifier=false", - image, "trace", packageSpec, } + arguments = appendScrubbedProxyEnvironment(arguments) + return append(arguments, image, "trace", packageSpec) +} + +func appendScrubbedProxyEnvironment(arguments []string) []string { + for _, name := range []string{ + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy", + } { + arguments = append(arguments, "--env", name+"=") + } + return arguments } func (runner *DockerRunner) imageDetails(ctx context.Context, image string) (string, string, error) { - result, err := runner.run(ctx, []string{"image", "inspect", image, "--format", "{{.Id}}"}, 64<<10, 64<<10) + result, err := runner.run(ctx, []string{"image", "inspect", image, "--format", "{{json .}}"}, 64<<10, 64<<10) if err != nil || result.ExitCode != 0 { return "", "", fmt.Errorf("inspect runner image: %s", safeDiagnostic(result.Stderr)) } - value := strings.TrimSpace(string(result.Stdout)) - if !strings.HasPrefix(value, "sha256:") { - return "", "", errors.New("runner image did not resolve to a content ID") + var inspected imageInspect + if err := json.Unmarshal(result.Stdout, &inspected); err != nil { + return "", "", fmt.Errorf("decode runner image metadata: %w", err) } - result, err = runner.run(ctx, []string{"image", "inspect", image, "--format", "{{.Architecture}}"}, 64<<10, 64<<10) - if err != nil || result.ExitCode != 0 { - return "", "", fmt.Errorf("inspect runner architecture: %s", safeDiagnostic(result.Stderr)) + if !validSHA256(inspected.ID) { + return "", "", errors.New("runner image did not resolve to a content ID") } - architecture := strings.TrimSpace(string(result.Stdout)) - if architecture == "" { + if inspected.Architecture == "" || len(inspected.Architecture) > 64 { return "", "", errors.New("runner image architecture is missing") } - return value, architecture, nil + return inspected.ID, inspected.Architecture, nil } -func (runner *DockerRunner) runnerMetadata(ctx context.Context) (RunnerMetadata, error) { +func (runner *DockerRunner) runnerMetadata(ctx context.Context, runnerImageID string) (RunnerMetadata, error) { arguments := []string{ "run", "--rm", "--network", "none", "--read-only", "--user", "65532:65532", - "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true", RunnerImage, "version", + "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true", } + arguments = appendScrubbedProxyEnvironment(arguments) + arguments = append(arguments, runnerImageID, "version") result, err := runner.run(ctx, arguments, 64<<10, 64<<10) if err != nil || result.ExitCode != 0 { return RunnerMetadata{}, fmt.Errorf("read runner versions: %s", safeDiagnostic(result.Stderr)) @@ -320,7 +340,7 @@ func parsePrepareMetadata(output []byte) (PrepareMetadata, error) { if err := json.Unmarshal([]byte(strings.TrimPrefix(line, prefix)), &metadata); err != nil { return PrepareMetadata{}, fmt.Errorf("decode preparation metadata: %w", err) } - if !strings.HasPrefix(metadata.Integrity, "sha512-") { + if !model.ValidRegistryIntegrity(metadata.Integrity) { return PrepareMetadata{}, errors.New("npm registry integrity metadata is missing or unsupported") } if !validSHA256(metadata.DependencyLockSHA256) { diff --git a/internal/capture/docker_test.go b/internal/capture/docker_test.go index c692e17..06fd837 100644 --- a/internal/capture/docker_test.go +++ b/internal/capture/docker_test.go @@ -10,10 +10,16 @@ import ( "github.com/kiranmagic7/behaviorlock/internal/npm" ) +const ( + testRunnerImageID = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + testPreparedImageID = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + testRegistryIntegrity = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" +) + func TestTraceArgumentsKeepPackageSpecAfterImage(t *testing.T) { t.Parallel() packageSpec := "safe-package@1.2.3" - arguments := buildTraceArgs("behaviorlock-trace-abc", "behaviorlock-analysis:abc", packageSpec) + arguments := buildTraceArgs("behaviorlock-trace-abc", testPreparedImageID, packageSpec) if arguments[len(arguments)-1] != packageSpec || arguments[len(arguments)-2] != "trace" { t.Fatalf("unexpected trailing arguments: %q", arguments[len(arguments)-3:]) } @@ -28,11 +34,12 @@ func TestTraceArgumentsKeepPackageSpecAfterImage(t *testing.T) { t.Fatalf("trace arguments missing %q: %s", required, joined) } } + assertProxyEnvironmentScrubbed(t, arguments) } func TestPrepareArgumentsNeverMountHostPaths(t *testing.T) { t.Parallel() - arguments := buildPrepareArgs("behaviorlock-prep-abc", "safe-package@1.2.3") + arguments := buildPrepareArgs("behaviorlock-prep-abc", testRunnerImageID, "safe-package@1.2.3") for _, argument := range arguments { if argument == "-v" || argument == "--volume" || strings.Contains(argument, "docker.sock") { t.Fatalf("prepare arguments expose a host mount: %q", arguments) @@ -41,6 +48,20 @@ func TestPrepareArgumentsNeverMountHostPaths(t *testing.T) { if !strings.Contains(strings.Join(arguments, " "), "--user 65532:65532") { t.Fatalf("preparation must run as the nonroot package user: %q", arguments) } + if arguments[len(arguments)-3] != testRunnerImageID { + t.Fatalf("preparation did not use the resolved runner image ID: %q", arguments) + } + assertProxyEnvironmentScrubbed(t, arguments) +} + +func assertProxyEnvironmentScrubbed(t *testing.T, arguments []string) { + t.Helper() + joined := strings.Join(arguments, " ") + for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"} { + if !strings.Contains(joined, "--env "+name+"=") { + t.Fatalf("docker arguments did not clear %s: %q", name, arguments) + } + } } func TestBoundedBufferReportsTruncation(t *testing.T) { @@ -56,11 +77,11 @@ func TestBoundedBufferReportsTruncation(t *testing.T) { func TestParsePrepareMetadata(t *testing.T) { t.Parallel() - metadata, err := parsePrepareMetadata([]byte("noise\nBEHAVIORLOCK_PREP_V1 {\"integrity\":\"sha512-abc\",\"dependencyLockSha256\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}\n")) + metadata, err := parsePrepareMetadata([]byte("noise\nBEHAVIORLOCK_PREP_V1 {\"integrity\":\"" + testRegistryIntegrity + "\",\"dependencyLockSha256\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}\n")) if err != nil { t.Fatal(err) } - if metadata.Integrity != "sha512-abc" { + if metadata.Integrity != testRegistryIntegrity { t.Fatalf("integrity = %q", metadata.Integrity) } } @@ -74,20 +95,30 @@ func TestCaptureCompletesOnlyWithValidEnvelope(t *testing.T) { return commandResult{Stdout: []byte("26.1.0\n")}, nil case "image": if arguments[1] == "inspect" { - if arguments[len(arguments)-1] == "{{.Architecture}}" { - return commandResult{Stdout: []byte("amd64\n")}, nil - } - return commandResult{Stdout: []byte("sha256:runner-image\n")}, nil + return commandResult{Stdout: []byte(`{"Id":"` + testRunnerImageID + `","Architecture":"amd64"}`)}, nil } return commandResult{}, nil - case "create", "commit", "rm": + case "create": + if arguments[len(arguments)-3] != testRunnerImageID { + t.Fatalf("prepare used mutable runner reference: %q", arguments) + } + return commandResult{}, nil + case "rm": return commandResult{}, nil + case "commit": + return commandResult{Stdout: []byte(testPreparedImageID + "\n")}, nil case "start": - return commandResult{Stdout: []byte("BEHAVIORLOCK_PREP_V1 {\"integrity\":\"sha512-test\",\"dependencyLockSha256\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}\n")}, nil + return commandResult{Stdout: []byte("BEHAVIORLOCK_PREP_V1 {\"integrity\":\"" + testRegistryIntegrity + "\",\"dependencyLockSha256\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}\n")}, nil case "run": if arguments[len(arguments)-1] == "version" { + if arguments[len(arguments)-2] != testRunnerImageID { + t.Fatalf("version probe used mutable runner reference: %q", arguments) + } return commandResult{Stdout: []byte("{\"node\":\"v22.1.0\",\"npm\":\"10.8.0\",\"strace\":\"6.1\"}\n")}, nil } + if arguments[len(arguments)-3] != testPreparedImageID { + t.Fatalf("trace used mutable preparation image reference: %q", arguments) + } return commandResult{Stdout: []byte("BEHAVIORLOCK_TRACE_V1\nopenat(AT_FDCWD, \"/opt/behaviorlock/sentinel-start\", O_RDONLY) = 3\nexecve(\"/bin/true\", [\"true\"], 0x0) = 0\nopenat(AT_FDCWD, \"/opt/behaviorlock/sentinel-end\", O_RDONLY) = 3\nBEHAVIORLOCK_TRACE_END exit=0\n")}, nil default: t.Fatalf("unexpected docker arguments: %q", arguments) @@ -105,7 +136,7 @@ func TestCaptureCompletesOnlyWithValidEnvelope(t *testing.T) { if profile.Result.Status != "complete" || len(profile.Behaviors) != 3 { t.Fatalf("unexpected profile: %#v", profile) } - if profile.Subject.RegistryIntegrity != "sha512-test" || profile.Capture.RunnerImageID != "sha256:runner-image" { + if profile.Subject.RegistryIntegrity != testRegistryIntegrity || profile.Capture.RunnerImageID != testRunnerImageID { t.Fatalf("capture evidence is incomplete: %#v", profile) } } @@ -118,14 +149,13 @@ func TestCaptureRejectsMissingTraceFooter(t *testing.T) { case "version": return commandResult{Stdout: []byte("26.1.0\n")}, nil case "image": - if arguments[len(arguments)-1] == "{{.Architecture}}" { - return commandResult{Stdout: []byte("amd64\n")}, nil - } - return commandResult{Stdout: []byte("sha256:runner-image\n")}, nil - case "create", "commit", "rm": + return commandResult{Stdout: []byte(`{"Id":"` + testRunnerImageID + `","Architecture":"amd64"}`)}, nil + case "create", "rm": return commandResult{}, nil + case "commit": + return commandResult{Stdout: []byte(testPreparedImageID + "\n")}, nil case "start": - return commandResult{Stdout: []byte("BEHAVIORLOCK_PREP_V1 {\"integrity\":\"sha512-test\",\"dependencyLockSha256\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}\n")}, nil + return commandResult{Stdout: []byte("BEHAVIORLOCK_PREP_V1 {\"integrity\":\"" + testRegistryIntegrity + "\",\"dependencyLockSha256\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}\n")}, nil case "run": if arguments[len(arguments)-1] == "version" { return commandResult{Stdout: []byte("{\"node\":\"v22.1.0\",\"npm\":\"10.8.0\",\"strace\":\"6.1\"}\n")}, nil @@ -163,3 +193,33 @@ func TestCaptureRejectsInvalidTimeoutBeforeDocker(t *testing.T) { t.Fatal("invalid timeout unexpectedly succeeded") } } + +func TestCaptureRejectsCommitWithoutContentID(t *testing.T) { + t.Parallel() + runner := &DockerRunner{dockerPath: "docker"} + runner.run = func(_ context.Context, arguments []string, _, _ int64) (commandResult, error) { + switch arguments[0] { + case "version": + return commandResult{Stdout: []byte("26.1.0\n")}, nil + case "image": + return commandResult{Stdout: []byte(`{"Id":"` + testRunnerImageID + `","Architecture":"amd64"}`)}, nil + case "create", "rm": + return commandResult{}, nil + case "start": + return commandResult{Stdout: []byte("BEHAVIORLOCK_PREP_V1 {\"integrity\":\"" + testRegistryIntegrity + "\",\"dependencyLockSha256\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}\n")}, nil + case "commit": + return commandResult{Stdout: []byte("behaviorlock-analysis:mutable\n")}, nil + case "run": + return commandResult{Stdout: []byte("{\"node\":\"v22.1.0\",\"npm\":\"10.8.0\",\"strace\":\"6.1\"}\n")}, nil + default: + return commandResult{}, nil + } + } + spec, err := npm.ParseExactSpec("example@1.2.3") + if err != nil { + t.Fatal(err) + } + if _, err := runner.Capture(context.Background(), spec, Config{Timeout: time.Minute, ToolVersion: "test"}); err == nil || !strings.Contains(err.Error(), "content ID") { + t.Fatalf("invalid commit result was not rejected: %v", err) + } +} diff --git a/internal/compare/compare_test.go b/internal/compare/compare_test.go index db3e0c7..a4e4532 100644 --- a/internal/compare/compare_test.go +++ b/internal/compare/compare_test.go @@ -6,13 +6,15 @@ import ( "github.com/kiranmagic7/behaviorlock/internal/model" ) +const testRegistryIntegrity = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + func completeProfile(version string, behaviors ...model.Behavior) model.Profile { profile := model.NewProfile(model.Subject{ Ecosystem: "npm", Name: "example", Version: version, PURL: "pkg:npm/example@" + version, - RegistryIntegrity: "sha512-test", DependencyLockSHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + RegistryIntegrity: testRegistryIntegrity, DependencyLockSHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }, "test") profile.Capture.RunnerImage = "behaviorlock-runner:test" - profile.Capture.RunnerImageID = "sha256:runner" + profile.Capture.RunnerImageID = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" profile.Capture.Architecture = "amd64" profile.Capture.NodeVersion = "v22.1.0" profile.Capture.NPMVersion = "10.8.0" diff --git a/internal/model/model.go b/internal/model/model.go index 6ff51a0..dbdd734 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -3,6 +3,8 @@ package model import ( "bytes" "crypto/sha256" + "crypto/sha512" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -21,6 +23,9 @@ const ( DiffSchemaVersion = "1.0.0" ProfileKind = "npm.install.profile" DiffKind = "npm.install.diff" + maxProfileBehaviors = 250_000 + maxBehaviorCount = 250_000 + maxCoverageLimits = 64 ) type ToolInfo struct { @@ -227,7 +232,7 @@ func ValidateProfile(p Profile) error { if p.Kind != ProfileKind { return fmt.Errorf("unsupported profile kind %q", p.Kind) } - if p.Tool.Name != "behaviorlock" || !safeField(p.Tool.Version, 128) { + if p.Tool.Name != "behaviorlock" || p.Tool.Version == "" || !safeField(p.Tool.Version, 128) { return errors.New("profile tool identity is invalid") } if p.Subject.Ecosystem != "npm" { @@ -237,7 +242,7 @@ func ValidateProfile(p Profile) error { if err != nil || p.Subject.PURL != spec.PURL() { return errors.New("profile subject name, version, and purl are inconsistent") } - if p.Subject.RegistryIntegrity != "" && !strings.HasPrefix(p.Subject.RegistryIntegrity, "sha512-") { + if p.Subject.RegistryIntegrity != "" && !ValidRegistryIntegrity(p.Subject.RegistryIntegrity) { return errors.New("profile registry integrity is invalid") } if p.Subject.TarballSHA256 != "" && !validDigest(p.Subject.TarballSHA256) { @@ -269,6 +274,15 @@ func ValidateProfile(p Profile) error { if p.Capture.Attestation != "none" { return errors.New("unsupported profile attestation") } + if !safeField(p.Capture.RunnerImage, 256) || !safeField(p.Capture.RunnerImageID, 256) || + !safeField(p.Capture.Architecture, 64) || !safeField(p.Capture.NodeVersion, 64) || + !safeField(p.Capture.NPMVersion, 64) || !safeField(p.Capture.StraceVersion, 64) || + !safeField(p.Capture.SandboxProfile, 128) { + return errors.New("capture metadata contains unsafe text") + } + if err := validateCoverage(p.Capture.Coverage); err != nil { + return err + } switch p.Capture.TraceIntegrity { case "isolated-root-tracer": if p.Capture.NetworkMode != "none" || p.Capture.SandboxProfile != "behaviorlock-linux-npm-v1" { @@ -287,15 +301,18 @@ func ValidateProfile(p Profile) error { return fmt.Errorf("captured profile %s is missing or unsafe", name) } } + if !validDigest(p.Capture.RunnerImageID) { + return errors.New("captured profile runner image id is invalid") + } if p.Subject.RegistryIntegrity == "" || p.Subject.DependencyLockSHA256 == "" { return errors.New("captured profile is missing acquisition provenance") } } - if p.Capture.Coverage.Scope != "registry-install-lifecycle" || !sameStrings(p.Capture.Coverage.Lifecycle, []string{"install", "postinstall", "preinstall"}) { + if p.Capture.Coverage.Scope != "registry-install-lifecycle" || p.Capture.Coverage.Completeness != "partial" || !sameStrings(p.Capture.Coverage.Lifecycle, []string{"install", "postinstall", "preinstall"}) { return errors.New("captured profile coverage is inconsistent") } case "external-unverified": - if p.Capture.NetworkMode != "unknown" || p.Capture.SandboxProfile != "external-unverified" || p.Capture.Coverage.Scope != "external-strace" { + if p.Capture.NetworkMode != "unknown" || p.Capture.SandboxProfile != "external-unverified" || p.Capture.Coverage.Scope != "external-strace" || p.Capture.Coverage.Completeness != "unverified" || len(p.Capture.Coverage.Lifecycle) != 0 { return errors.New("external profile must not attest sandbox conditions") } default: @@ -307,6 +324,9 @@ func ValidateProfile(p Profile) error { if (p.Result.Status == "complete" || p.Result.Status == "command_failed") && len(p.Behaviors) == 0 { return errors.New("completed profile contains no recognized behavior") } + if len(p.Behaviors) > maxProfileBehaviors { + return fmt.Errorf("profile exceeds %d behaviors", maxProfileBehaviors) + } for index, behavior := range p.Behaviors { if err := validateBehavior(behavior); err != nil { return fmt.Errorf("behavior %d: %w", index, err) @@ -347,6 +367,9 @@ func ReadProfile(path string) (Profile, error) { return Profile{}, err } profile.Normalize() + if err := ValidateProfile(profile); err != nil { + return Profile{}, fmt.Errorf("normalized profile: %w", err) + } return profile, nil } @@ -367,7 +390,7 @@ func validateBehavior(behavior Behavior) error { return errors.New("argument is unsafe") } } - if len(behavior.Arguments) > 32 || behavior.Count < 1 { + if len(behavior.Arguments) > 32 || behavior.Count < 1 || behavior.Count > maxBehaviorCount { return errors.New("argument or count limit is invalid") } switch behavior.Outcome { @@ -384,6 +407,26 @@ func validateBehavior(behavior Behavior) error { return nil } +func validateCoverage(coverage CaptureCoverage) error { + if !safeField(coverage.Scope, 64) || !safeField(coverage.Completeness, 64) || + len(coverage.Lifecycle) > 3 || len(coverage.Limitations) > maxCoverageLimits { + return errors.New("profile capture coverage exceeds its limits") + } + for _, lifecycle := range coverage.Lifecycle { + switch lifecycle { + case "preinstall", "install", "postinstall": + default: + return errors.New("profile capture lifecycle is invalid") + } + } + for _, limitation := range coverage.Limitations { + if limitation == "" || !safeField(limitation, 1024) { + return errors.New("profile capture limitation is unsafe") + } + } + return nil +} + func safeField(value string, limit int) bool { if !utf8.ValidString(value) || len(value) > limit { return false @@ -404,6 +447,15 @@ func validDigest(value string) bool { return err == nil } +func ValidRegistryIntegrity(value string) bool { + const prefix = "sha512-" + if !strings.HasPrefix(value, prefix) || len(value) > 256 || !safeField(value, 256) { + return false + } + digest, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, prefix)) + return err == nil && len(digest) == sha512.Size +} + func sameStrings(left, right []string) bool { if len(left) != len(right) { return false diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 20e8b31..e04cf5a 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -6,17 +6,19 @@ import ( "testing" ) +const testRegistryIntegrity = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + func testProfile() Profile { profile := NewProfile(Subject{ Ecosystem: "npm", Name: "example", Version: "1.2.3", PURL: "pkg:npm/example@1.2.3", - RegistryIntegrity: "sha512-test", + RegistryIntegrity: testRegistryIntegrity, DependencyLockSHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }, "test") profile.Capture.RunnerImage = "behaviorlock-runner:test" - profile.Capture.RunnerImageID = "sha256:runner" + profile.Capture.RunnerImageID = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" profile.Capture.Architecture = "amd64" profile.Capture.NodeVersion = "v22.1.0" profile.Capture.NPMVersion = "10.8.0" @@ -153,6 +155,34 @@ func TestValidateProfileRejectsContradictoryCompletion(t *testing.T) { } } +func TestValidateProfileRejectsMalformedAcquisitionEvidence(t *testing.T) { + t.Parallel() + profile := testProfile() + profile.Subject.RegistryIntegrity = "sha512-not-a-real-digest" + if err := ValidateProfile(profile); err == nil { + t.Fatal("malformed registry integrity unexpectedly validated") + } + profile = testProfile() + profile.Capture.RunnerImageID = "behaviorlock-runner:mutable" + if err := ValidateProfile(profile); err == nil { + t.Fatal("mutable runner image reference unexpectedly validated as an ID") + } +} + +func TestValidateProfileRejectsUnsafeCoverage(t *testing.T) { + t.Parallel() + profile := testProfile() + profile.Capture.Coverage.Limitations = []string{"unsafe\nworkflow command"} + if err := ValidateProfile(profile); err == nil { + t.Fatal("unsafe coverage limitation unexpectedly validated") + } + profile = testProfile() + profile.Capture.Coverage.Completeness = "complete" + if err := ValidateProfile(profile); err == nil { + t.Fatal("unsupported coverage completeness unexpectedly validated") + } +} + func TestSeverityRanksAreOrdered(t *testing.T) { t.Parallel() levels := []string{"none", "low", "medium", "high", "critical"} diff --git a/internal/trace/parser.go b/internal/trace/parser.go index 14c5a06..2a645f8 100644 --- a/internal/trace/parser.go +++ b/internal/trace/parser.go @@ -274,8 +274,8 @@ func classifyResult(value string) (string, string) { func normalizePath(value string) string { value = sanitize(value) - value = strings.ReplaceAll(value, "/home/scanner", "$HOME") - value = strings.ReplaceAll(value, "/work", "$WORK") + value = normalizeRoot(value, "/home/scanner", "$HOME") + value = normalizeRoot(value, "/work", "$WORK") value = procPIDPattern.ReplaceAllString(value, "/proc/$PID") value = tmpPattern.ReplaceAllString(value, "$TMP") value = filepath.Clean(value) @@ -285,6 +285,16 @@ func normalizePath(value string) string { return value } +func normalizeRoot(value, root, replacement string) string { + if value == root { + return replacement + } + if strings.HasPrefix(value, root+"/") { + return replacement + strings.TrimPrefix(value, root) + } + return value +} + func isSensitivePath(value string) bool { lower := strings.ToLower(value) patterns := []string{ diff --git a/internal/trace/parser_test.go b/internal/trace/parser_test.go index 0d0c263..8194ca2 100644 --- a/internal/trace/parser_test.go +++ b/internal/trace/parser_test.go @@ -36,6 +36,26 @@ func TestParseRejectsUnfinishedTrace(t *testing.T) { } } +func TestNormalizePathReplacesOnlyRootBoundaries(t *testing.T) { + t.Parallel() + input := strings.Join([]string{ + `openat(AT_FDCWD, "/work/output.txt", O_WRONLY|O_CREAT, 0600) = 3`, + `openat(AT_FDCWD, "/workspace/output.txt", O_WRONLY|O_CREAT, 0600) = 3`, + `openat(AT_FDCWD, "/home/scanner/.npmrc", O_RDONLY) = 3`, + `openat(AT_FDCWD, "/home/scanner-backup/.npmrc", O_RDONLY) = 3`, + }, "\n") + result, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + want := []string{"$WORK/output.txt", "/workspace/output.txt", "$HOME/.npmrc", "/home/scanner-backup/.npmrc"} + for index, target := range want { + if result.Behaviors[index].Target != target { + t.Fatalf("target %d = %q, want %q", index, result.Behaviors[index].Target, target) + } + } +} + func TestParseEnvelopeRequiresCompletionMarker(t *testing.T) { t.Parallel() _, _, _, err := ParseEnvelope([]byte("BEHAVIORLOCK_TRACE_V1\nexecve(\"/bin/true\", [\"true\"], 0x0) = 0\n")) diff --git a/runner/entrypoint.sh b/runner/entrypoint.sh index e6b9074..7ad851a 100755 --- a/runner/entrypoint.sh +++ b/runner/entrypoint.sh @@ -43,6 +43,11 @@ case "$mode" in 2> /tmp/strace-error.log command_exit=$? set -e + if [ -s /tmp/strace-error.log ]; then + echo "strace reported diagnostics; capture is incomplete" >&2 + sed -n '1,20p' /tmp/strace-error.log >&2 + exit 72 + fi trace_found=false for trace_file in /trace/raw*; do if [ -f "$trace_file" ]; then diff --git a/schemas/diff-v1.schema.json b/schemas/diff-v1.schema.json index c3f6391..475f003 100644 --- a/schemas/diff-v1.schema.json +++ b/schemas/diff-v1.schema.json @@ -79,7 +79,7 @@ "outcome": { "enum": ["success", "blocked", "failed", "unknown"] }, "errno": { "type": "string", "maxLength": 128 }, "sensitive": { "type": "boolean" }, - "count": { "type": "integer", "minimum": 1 }, + "count": { "type": "integer", "minimum": 1, "maximum": 250000 }, "evidence": { "type": "string", "pattern": "^event:sha256:[0-9a-f]{64}$" }, "sourceSyscall": { "type": "string", "minLength": 1, "maxLength": 64 } }, diff --git a/schemas/profile-v1.schema.json b/schemas/profile-v1.schema.json index 426694e..b3d0554 100644 --- a/schemas/profile-v1.schema.json +++ b/schemas/profile-v1.schema.json @@ -95,6 +95,7 @@ "completeness": { "enum": ["partial", "unverified"] }, "limitations": { "type": "array", + "maxItems": 64, "items": { "type": "string", "minLength": 1, "maxLength": 1024 } } }, @@ -210,7 +211,7 @@ "outcome": { "enum": ["success", "blocked", "failed", "unknown"] }, "errno": { "type": "string", "maxLength": 128 }, "sensitive": { "type": "boolean" }, - "count": { "type": "integer", "minimum": 1 }, + "count": { "type": "integer", "minimum": 1, "maximum": 250000 }, "evidence": { "type": "string", "pattern": "^event:sha256:[0-9a-f]{64}$" }, "sourceSyscall": { "type": "string", "minLength": 1, "maxLength": 64 } }, diff --git a/schemas/schema_test.go b/schemas/schema_test.go index 0d76aba..b722c27 100644 --- a/schemas/schema_test.go +++ b/schemas/schema_test.go @@ -9,13 +9,15 @@ import ( jsonschema "github.com/santhosh-tekuri/jsonschema/v6" ) +const testRegistryIntegrity = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + func schemaProfile(version string, behaviors ...model.Behavior) model.Profile { profile := model.NewProfile(model.Subject{ Ecosystem: "npm", Name: "example", Version: version, PURL: "pkg:npm/example@" + version, - RegistryIntegrity: "sha512-test", DependencyLockSHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + RegistryIntegrity: testRegistryIntegrity, DependencyLockSHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }, "test") profile.Capture.RunnerImage = "behaviorlock-runner:test" - profile.Capture.RunnerImageID = "sha256:runner" + profile.Capture.RunnerImageID = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" profile.Capture.Architecture = "amd64" profile.Capture.NodeVersion = "v22.1.0" profile.Capture.NPMVersion = "10.8.0" diff --git a/scripts/integration-runner.sh b/scripts/integration-runner.sh index dfd306a..a9afd96 100755 --- a/scripts/integration-runner.sh +++ b/scripts/integration-runner.sh @@ -7,6 +7,47 @@ trace_output="$temp_dir/envelope.txt" raw_trace="$temp_dir/raw.strace" profile="$temp_dir/profile.json" +run_trace_container() { + runner_image="$1" + docker run --rm \ + --network none \ + --read-only \ + --user 0:0 \ + --cap-drop ALL \ + --cap-add SETUID \ + --cap-add SETGID \ + --cap-add SYS_PTRACE \ + --security-opt no-new-privileges:true \ + --pids-limit 128 \ + --memory 512m \ + --memory-swap 512m \ + --cpus 1 \ + --ulimit nofile=1024:1024 \ + --ulimit nproc=128:128 \ + --ulimit core=0:0 \ + --shm-size 16m \ + --ipc none \ + --tmpfs /work:rw,exec,nosuid,nodev,size=384m,uid=65532,gid=65532,mode=0700 \ + --tmpfs /tmp:rw,exec,nosuid,nodev,size=96m,uid=0,gid=0,mode=1777 \ + --tmpfs /home/scanner:rw,nosuid,nodev,size=8m,uid=65532,gid=65532,mode=0700 \ + --tmpfs /trace:rw,nosuid,nodev,noexec,size=128m,uid=0,gid=0,mode=0700 \ + --env HOME=/home/scanner \ + --env npm_config_cache=/work/.npm-cache \ + --env npm_config_userconfig=/dev/null \ + --env npm_config_audit=false \ + --env npm_config_fund=false \ + --env npm_config_update_notifier=false \ + --env HTTP_PROXY= \ + --env HTTPS_PROXY= \ + --env ALL_PROXY= \ + --env NO_PROXY= \ + --env http_proxy= \ + --env https_proxy= \ + --env all_proxy= \ + --env no_proxy= \ + "$runner_image" trace behaviorlock-fixture@1.0.0 +} + require_trace_match() { pattern="$1" description="$2" @@ -19,36 +60,7 @@ require_trace_match() { } docker build --pull=false --tag behaviorlock-runner-fixture:dev testdata/npm-fixture - -docker run --rm \ - --network none \ - --read-only \ - --user 0:0 \ - --cap-drop ALL \ - --cap-add SETUID \ - --cap-add SETGID \ - --cap-add SYS_PTRACE \ - --security-opt no-new-privileges:true \ - --pids-limit 128 \ - --memory 512m \ - --memory-swap 512m \ - --cpus 1 \ - --ulimit nofile=1024:1024 \ - --ulimit nproc=128:128 \ - --ulimit core=0:0 \ - --shm-size 16m \ - --ipc none \ - --tmpfs /work:rw,exec,nosuid,nodev,size=384m,uid=65532,gid=65532,mode=0700 \ - --tmpfs /tmp:rw,exec,nosuid,nodev,size=96m,uid=0,gid=0,mode=1777 \ - --tmpfs /home/scanner:rw,nosuid,nodev,size=8m,uid=65532,gid=65532,mode=0700 \ - --tmpfs /trace:rw,nosuid,nodev,noexec,size=128m,uid=0,gid=0,mode=0700 \ - --env HOME=/home/scanner \ - --env npm_config_cache=/work/.npm-cache \ - --env npm_config_userconfig=/dev/null \ - --env npm_config_audit=false \ - --env npm_config_fund=false \ - --env npm_config_update_notifier=false \ - behaviorlock-runner-fixture:dev trace behaviorlock-fixture@1.0.0 > "$trace_output" +run_trace_container behaviorlock-runner-fixture:dev > "$trace_output" require_trace_match '^BEHAVIORLOCK_TRACE_V1$' 'missing trace header' require_trace_match '^BEHAVIORLOCK_TRACE_END exit=0$' 'lifecycle or tracer returned nonzero' @@ -97,3 +109,16 @@ if docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -Eq '^behaviorlock echo "capture left a temporary image behind" >&2 exit 1 fi + +docker build --pull=false --tag behaviorlock-tracer-failure:dev testdata/tracer-failure +failure_output="$temp_dir/tracer-failure.out" +failure_error="$temp_dir/tracer-failure.err" +if run_trace_container behaviorlock-tracer-failure:dev > "$failure_output" 2> "$failure_error"; then + echo "runner accepted tracer diagnostics as a complete capture" >&2 + exit 1 +fi +grep -q 'strace reported diagnostics; capture is incomplete' "$failure_error" +if grep -q '^BEHAVIORLOCK_TRACE_END ' "$failure_output"; then + echo "failed tracer emitted a trusted completion footer" >&2 + exit 1 +fi diff --git a/testdata/npm-fixture/seed/node_modules/behaviorlock-fixture/index.js b/testdata/npm-fixture/seed/node_modules/behaviorlock-fixture/index.js index 5881dc5..ee2d190 100644 --- a/testdata/npm-fixture/seed/node_modules/behaviorlock-fixture/index.js +++ b/testdata/npm-fixture/seed/node_modules/behaviorlock-fixture/index.js @@ -8,6 +8,15 @@ if (process.getuid() !== 65532) { throw new Error(`fixture expected uid 65532, received ${process.getuid()}`); } +for (const name of [ + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', +]) { + if ((process.env[name] ?? '') !== '') { + throw new Error(`fixture received inherited proxy configuration in ${name}`); + } +} + const status = fs.readFileSync('/proc/self/status', 'utf8'); const effectiveCapabilities = /^CapEff:\s*([0-9a-f]+)$/im.exec(status)?.[1]; if (!effectiveCapabilities || !/^0+$/.test(effectiveCapabilities)) { diff --git a/testdata/tracer-failure/Dockerfile b/testdata/tracer-failure/Dockerfile new file mode 100644 index 0000000..c6bad42 --- /dev/null +++ b/testdata/tracer-failure/Dockerfile @@ -0,0 +1,3 @@ +FROM behaviorlock-runner:dev + +COPY --chmod=0555 fake-strace.sh /usr/local/bin/strace diff --git a/testdata/tracer-failure/fake-strace.sh b/testdata/tracer-failure/fake-strace.sh new file mode 100644 index 0000000..2f25cde --- /dev/null +++ b/testdata/tracer-failure/fake-strace.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +trace_prefix="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + shift + trace_prefix="${1:-}" + break + fi + shift +done + +if [ -z "$trace_prefix" ]; then + echo "fake tracer did not receive an output path" >&2 + exit 64 +fi + +printf '%s\n' \ + 'openat(AT_FDCWD, "/opt/behaviorlock/sentinel-start", O_RDONLY) = 3' \ + 'openat(AT_FDCWD, "/opt/behaviorlock/sentinel-end", O_RDONLY) = 3' \ + > "${trace_prefix}.1" +echo "simulated tracer diagnostic" >&2 +exit 0