Introduction
LockGuard is a command-line tool that scans PHP project dependencies for known security vulnerabilities. It reads a composer.lock file, queries the official Packagist Security Advisories API, and reports any packages whose installed versions match a published advisory.
Why use it
If you manage a PHP project — Laravel, Symfony, Drupal, WordPress plugin, or anything else using Composer — your composer.lock pins exact versions of sometimes hundreds of packages. Security advisories are published regularly, but checking them manually is tedious and easy to forget. LockGuard automates the check in a single command.
It is designed for two contexts:
- Local development — run it before deploying to catch vulnerable dependencies.
- CI pipelines — run it on every push or pull request. The JSON output and documented exit codes make it scriptable.
What it does
- Reads
composer.lock(or a path you specify). - Combines
packagesandpackages-devsections, deduplicates, and sorts. - Sends package names to the Packagist Security Advisories API in small batches.
- Matches each installed version against advisory constraints using Composer-compatible semantics (caret, tilde, wildcards, ranges, OR, hyphens, stability flags).
- Filters findings by severity threshold.
- Outputs a text or JSON report.
- Returns an exit code:
0for clean,1for vulnerabilities found,2for errors.
What it does not do
LockGuard is an audit tool, not a remediation tool. It will not:
- Modify your
composer.lockor runcomposer update. - Suggest which version to upgrade to (though the
affected_versionsfield tells you the vulnerable range). - Cache results or run offline.
- Scan containers, repositories, or non-Composer projects.
- Send alerts or integrate with notification systems.
These are deliberate scope boundaries for the initial release. The tool does one thing: tell you what's vulnerable and why.
Dependencies
LockGuard is a standalone Rust binary. It does not require PHP, Composer, or any runtime service on your machine. The only network call is to packagist.org for advisory data.
The project depends on composer-semver for version constraint matching. This crate is a faithful port of PHP's Composer semver library, so matching behavior matches what Composer itself would evaluate.
Installation
LockGuard is distributed through several channels. Pick whichever fits your environment.
Cargo
The simplest method if you have Rust installed:
cargo install lockguard
This downloads, compiles, and installs the binary to ~/.cargo/bin/lockguard. Make sure ~/.cargo/bin is on your PATH.
To upgrade to a newer version later, run the same command again. Cargo will overwrite the existing binary.
One-liner install script (Linux)
If you don't have Rust and just want the binary:
curl -fsSL https://github.com/alinaqi2000/lockguard/releases/latest/download/install.sh | sh
This detects your architecture, downloads the latest release binary, and installs it to /usr/local/bin/lockguard. It requires sudo for the final install step.
To install a specific version, replace latest with the tag:
curl -fsSL https://github.com/alinaqi2000/lockguard/releases/download/v0.1.4/install.sh | sh
Debian / Ubuntu
Download the .deb package from the releases page and install with dpkg:
wget https://github.com/alinaqi2000/lockguard/releases/latest/download/lockguard_0.1.4_amd64.deb
sudo dpkg -i lockguard_0.1.4_amd64.deb
After installation, lockguard is available at /usr/bin/lockguard.
To verify the package integrity, check the SHA256 checksum:
sha256sum -c lockguard_0.1.4_amd64.deb.sha256
Fedora / RHEL / openSUSE
Download the .rpm package and install with rpm:
sudo rpm -i https://github.com/alinaqi2000/lockguard/releases/latest/download/lockguard-0.1.4-1.x86_64.rpm
To verify:
sha256sum -c lockguard-0.1.4-1.x86_64.rpm.sha256
Arch Linux (AUR)
An AUR package lockguard-bin is available. Install with your AUR helper:
yay -S lockguard-bin
Build from source
If you need a debug build, want to modify the code, or are on an architecture without pre-built binaries:
git clone https://github.com/alinaqi2000/lockguard.git
cd lockguard
cargo build --release
The binary will be at target/release/lockguard. Copy it to wherever you keep local binaries:
cp target/release/lockguard ~/.local/bin/
Verify the installation
Regardless of how you installed it, verify it works:
lockguard --version
Should print:
lockguard 0.1.4
Quick Start
Basic audit
Navigate to any directory containing a composer.lock file and run:
lockguard
Output:
Auditing composer.lock...
Found 1 packages with known vulnerabilities.
[HIGH] league/commonmark 2.8.3
- Advisory: PKSA-1q6p-sqkj-8mmj
- league/commonmark: Denial of service via duplicate footnote definitions
- Affected: >=1.5.0,<2.9.0
- Link: https://github.com/advisories/GHSA-jfm3-95jq-q3rf
- Sources: GitHub:GHSA-jfm3-95jq-q3rf
Summary:
- Total packages: 137
- Vulnerable packages: 1
- Critical: 0
- High: 1
- Medium: 0
- Low: 0
- Unknown: 0
The exit code is 1 because vulnerabilities were found. You can check it with:
echo $?
Clean audit
If no vulnerabilities are found:
Auditing composer.lock...
No known vulnerabilities found.
Summary:
- Total packages: 42
- Vulnerable packages: 0
- Critical: 0
- High: 0
- Medium: 0
- Low: 0
- Unknown: 0
Exit code is 0.
JSON output for scripting
lockguard --format json
This produces a single JSON document on stdout. Diagnostics and progress messages go to stderr, so piping the JSON to another tool works cleanly:
lockguard --format json | jq '.findings | length'
Filter by severity
Only report high and critical vulnerabilities:
lockguard --min-severity high
Audit a specific lock file
lockguard --lock /path/to/project/composer.lock
What to do with findings
LockGuard tells you what's vulnerable and links to the advisory. To fix a finding:
- Read the advisory link to understand the vulnerability.
- Check the
affected_versionsfield — it tells you the vulnerable range. - Update the package to a version outside that range:
composer update vendor/package
- Re-run LockGuard to confirm the fix:
lockguard
Command-Line Options
LockGuard accepts the following options. All have sensible defaults, so you can run lockguard with no arguments in a directory containing composer.lock.
--lock <PATH>
Path to the Composer lock file to audit.
Default: composer.lock
lockguard --lock /var/www/myapp/composer.lock
The file must be valid JSON with a packages array. A packages-dev array is optional and defaults to empty. Other fields in the lock file (like content-hash, aliases, stability-flags) are ignored.
--format <text|json>
Output format for the audit report.
Default: text
lockguard --format json
text— human-readable report with findings and summary.json— a single JSON document on stdout, suitable for piping tojqor another tool.
In both modes, progress messages and warnings go to stderr. stdout contains only the report.
--min-severity <low|medium|high|critical>
Minimum severity threshold. Findings below this severity are excluded from the report.
Default: low (reports everything)
lockguard --min-severity high
The threshold includes the named severity and above. For example, --min-severity medium reports medium, high, and critical findings, but excludes low.
See the Severity Handling chapter for how unknown-severity advisories interact with the threshold.
--help
Prints usage information and exits.
lockguard --help
Audit composer.lock for known security vulnerabilities
Usage: lockguard [OPTIONS]
Options:
--lock <LOCK> Path to composer.lock [default: composer.lock]
--format <FORMAT> Output format [default: text] [possible values: text, json]
--min-severity <MIN_SEVERITY> Minimum severity to report [default: low] [possible values: low, medium, high, critical]
-h, --help Print help
-V, --version Print version
--version
Prints the binary version and exits.
lockguard --version
lockguard 0.1.4
Combining options
All options can be combined:
lockguard --lock /path/to/composer.lock --format json --min-severity critical
This audits a specific lock file, outputs JSON, and only reports critical-severity findings.
Output Formats
LockGuard produces two output formats from the same internal audit result. Both contain identical information — the format only affects presentation.
Text format
The default. Designed to be readable in a terminal without color or special formatting.
Clean audit (no findings)
Auditing composer.lock...
No known vulnerabilities found.
Summary:
- Total packages: 42
- Vulnerable packages: 0
- Critical: 0
- High: 0
- Medium: 0
- Low: 0
- Unknown: 0
Vulnerable audit
Auditing composer.lock...
Found 2 packages with known vulnerabilities.
[CRITICAL] vendor/critical-pkg 1.0.0
- Advisory: PKSA-aaaa-bbbb-cccc
- CVE: CVE-2024-1234
- Remote code execution in critical-pkg
- Affected: >=1.0.0,<1.2.0
- Link: https://github.com/advisories/GHSA-xxxx
- Sources: GitHub:GHSA-xxxx, FriendsOfPHP:vendor/critical-pkg/2024.yaml
[HIGH] vendor/high-pkg 3.4.5
- Advisory: PKSA-dddd-eeee-ffff
- SQL injection in high-pkg
- Affected: >=3.0.0,<3.5.0
- Link: https://github.com/advisories/GHSA-yyyy
- Sources: GitHub:GHSA-yyyy
Summary:
- Total packages: 42
- Vulnerable packages: 2
- Critical: 1
- High: 1
- Medium: 0
- Low: 0
- Unknown: 0
Finding fields
Each finding block contains:
| Field | Always shown | Description |
|---|---|---|
| Severity label | yes | [CRITICAL], [HIGH], [MEDIUM], [LOW], or [UNKNOWN] |
| Package and version | yes | vendor/package 1.2.3 |
| Advisory ID | yes | Stable Packagist advisory identifier |
| CVE | only if present | CVE identifier from the advisory |
| Title | yes | Short description of the vulnerability |
| Affected versions | yes | Composer constraint string showing the vulnerable range |
| Link | only if present | URL to the full advisory details |
| Sources | only if present | Comma-separated list of source name and remote ID |
Ordering
Findings are sorted by:
- Severity descending (critical first, unknown last)
- Package name alphabetically
- Installed version alphabetically
- Advisory ID alphabetically
This ordering is deterministic — the same input always produces the same output.
JSON format
Enabled with --format json. Produces a single JSON document on stdout.
Schema
{
"total_packages": 42,
"vulnerable_packages": 1,
"findings": [
{
"package": "league/commonmark",
"version": "2.8.3",
"advisory_id": "PKSA-1q6p-sqkj-8mmj",
"severity": "high",
"cve": null,
"title": "league/commonmark: Denial of service via duplicate footnote definitions",
"affected_versions": ">=1.5.0,<2.9.0",
"link": "https://github.com/advisories/GHSA-jfm3-95jq-q3rf",
"sources": [
{
"name": "GitHub",
"remote_id": "GHSA-jfm3-95jq-q3rf"
}
]
}
],
"summary": {
"critical": 0,
"high": 1,
"medium": 0,
"low": 0,
"unknown": 0
}
}
Top-level fields
| Field | Type | Description |
|---|---|---|
total_packages | integer | Total number of packages audited (packages + packages-dev, deduplicated) |
vulnerable_packages | integer | Number of unique packages with at least one reportable finding |
findings | array | One entry per matching advisory, sorted as described above |
summary | object | Count of findings by severity |
Finding fields
| Field | Type | Nullable | Description |
|---|---|---|---|
package | string | no | Lowercased package name |
version | string | no | Installed version exactly as in the lock file |
advisory_id | string | no | Packagist advisory identifier |
severity | string | no | critical, high, medium, low, or unknown |
cve | string | yes | CVE identifier, or null if not assigned |
title | string | no | Advisory title |
affected_versions | string | no | Composer constraint string for the vulnerable range |
link | string | yes | URL to advisory details, or null if not provided |
sources | array | no | Array of source objects (empty array if none) |
Source object
| Field | Type | Description |
|---|---|---|
name | string | Source name (e.g., GitHub, FriendsOfPHP/security-advisories) |
remote_id | string | Source-specific identifier (e.g., GHSA ID) |
Null policy
Nullable fields (cve, link) always serialize as null when absent — they are never omitted. The sources array is always present, empty if no sources exist. This is consistent across all responses and versions.
Parsing with jq
Count findings:
lockguard --format json | jq '.findings | length'
List affected packages:
lockguard --format json | jq -r '.findings[].package' | sort -u
Extract CVEs:
lockguard --format json | jq '.findings[].cve' | grep -v null
Check if any critical findings exist:
lockguard --format json | jq '.summary.critical > 0'
Stream separation
In both formats:
- stdout — the report only (text or JSON).
- stderr — progress messages (
Auditing composer.lock...), warnings (warning: no advisory data for 'vendor/pkg' — coverage unknown), retry notices, and error messages.
This means lockguard --format json > report.json produces a clean JSON file with no interleaved text. Errors still appear in the terminal because stderr is not redirected.
Exit Codes
LockGuard uses three exit codes. This separation lets CI pipelines distinguish between "no vulnerabilities," "vulnerabilities found," and "the tool itself failed."
| Code | Meaning | When |
|---|---|---|
0 | Clean audit | Audit completed successfully and no findings met the severity threshold |
1 | Vulnerabilities found | Audit completed successfully and one or more findings met the threshold |
2 | Operational error | The audit could not complete due to invalid input, network failure, or malformed data |
Exit code 0 — clean
The lock file was read, packages were queried, and no reportable vulnerabilities were found. This includes:
- All packages returned with empty advisory arrays (known clean).
- Packages had advisories but none matched the installed version.
- All matching advisories were filtered out by
--min-severity.
Exit code 1 — vulnerabilities found
At least one advisory matched an installed version and met the severity threshold. The findings are in the report on stdout.
This is not an error. The tool worked correctly — it found vulnerabilities, which is its job. CI pipelines that treat any non-zero exit as a failure should special-case exit code 1.
Exit code 2 — operational error
The audit could not complete. Common causes:
- Lock file missing or unreadable — the path does not exist or permissions prevent reading.
- Invalid JSON — the lock file is not valid JSON.
- Missing required fields — the lock file lacks a
packagesarray or a package is missing its name or version. - Conflicting versions — the same package appears in
packagesandpackages-devwith different versions. - HTTP error — Packagist returned a non-transient error (e.g., 404, 400) after retries.
- Malformed response — Packagist returned data that could not be parsed as JSON.
- Network failure — connection timed out or was refused after retries.
The error message is printed to stderr with enough context to diagnose the problem:
error: failed to read lock file /path/to/composer.lock: No such file or directory
error: Packagist API returned status 500 for https://packagist.org/api/security-advisories?packages[]=vendor/pkg
CI usage examples
Fail the build on any vulnerability
lockguard --format json --min-severity high
# exit 0 = clean, exit 1 = fail the build, exit 2 = infrastructure error
Don't fail, just report
lockguard || true
Distinguish vulnerability from error
lockguard --format json > report.json
code=$?
if [ $code -eq 1 ]; then
echo "Vulnerabilities found — see report.json"
# notify team, create ticket, etc.
elif [ $code -eq 2 ]; then
echo "Audit failed — check infrastructure"
exit 1
fi
GitHub Actions
- name: Audit dependencies
run: lockguard --format json --min-severity high
continue-on-error: true
id: audit
- name: Report vulnerabilities
if: steps.audit.outcome == 'failure'
run: |
echo "::warning::Vulnerabilities found in composer.lock"
cat report.json | jq '.findings[]'
Severity Handling
Packagist advisories may include a severity field, but it is optional. Some advisories have no severity at all, and some use labels that LockGuard does not recognize. This chapter explains how severity is normalized, displayed, and filtered.
Severity levels
LockGuard recognizes four severity levels, plus an explicit unknown state:
| Level | String value | Numeric rank |
|---|---|---|
| Critical | critical | 4 |
| High | high | 3 |
| Medium | medium | 2 |
| Low | low | 1 |
| Unknown | unknown | 1 |
Normalization
When an advisory includes a severity field, LockGuard normalizes it case-insensitively:
"high","HIGH","High"→high"critical","CRITICAL"→critical"medium","Medium"→medium"low","LOW"→low
Any other value — including unrecognized strings like "moderate", "info", or "none" — becomes unknown. If the severity field is absent or null, the finding is also unknown.
Severity is never inferred from the advisory title, CVE, advisory ID, or source. If Packagist does not provide a severity, the finding is reported as unknown — not guessed.
Threshold filtering
The --min-severity flag controls which findings appear in the report. A finding is included if its severity rank is greater than or equal to the threshold rank.
Truth table
| Finding severity | --min-severity low | --min-severity medium | --min-severity high | --min-severity critical |
|---|---|---|---|---|
| Critical | included | included | included | included |
| High | included | included | included | excluded |
| Medium | included | included | excluded | excluded |
| Low | included | excluded | excluded | excluded |
| Unknown | included | excluded | excluded | excluded |
Unknown severity behavior
Unknown-severity findings are treated as rank 1 (same as low). This means:
- At the default
--min-severity low, unknown-severity findings are always reported. This is the honest default — if we don't know the severity, we report it so the user can investigate. - At
--min-severity mediumor higher, unknown-severity findings are excluded. We cannot claim an unknown-severity advisory meets a higher bar.
This behavior is consistent across text output, JSON output, summary counts, and exit codes. The summary includes a separate unknown count so you can see how many findings had no severity.
Display
Text format
Severity is shown as an uppercase label in brackets:
[CRITICAL] vendor/pkg 1.0.0
[HIGH] vendor/pkg 2.0.0
[MEDIUM] vendor/pkg 3.0.0
[LOW] vendor/pkg 4.0.0
[UNKNOWN] vendor/pkg 5.0.0
JSON format
Severity is a lowercase string:
"severity": "high"
"severity": "unknown"
Summary counts
The summary section counts findings by severity, including unknown:
Summary:
- Critical: 0
- High: 4
- Medium: 2
- Low: 0
- Unknown: 0
In JSON:
"summary": {
"critical": 0,
"high": 4,
"medium": 2,
"low": 0,
"unknown": 0
}
The counts reflect the findings after severity filtering. If --min-severity high excludes low and medium findings, they do not appear in the summary counts.
CI/CD Pipelines
LockGuard is designed to run in automated pipelines. The JSON output, documented exit codes, and clean stream separation make it straightforward to integrate.
GitHub Actions
Basic vulnerability check
Run LockGuard on every push and fail if high or critical vulnerabilities are found:
name: Security audit
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install lockguard
run: |
curl -fsSL https://github.com/alinaqi2000/lockguard/releases/latest/download/install.sh | sh
- name: Audit dependencies
run: lockguard --min-severity high
This fails the build (exit code 1) if any high or critical vulnerabilities are found. Exit code 2 (infrastructure error) also fails the build.
Report without failing
If you want to see vulnerabilities but not block the pipeline:
- name: Audit dependencies
run: lockguard --format json > audit-report.json || true
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: security-audit
path: audit-report.json
Separate vulnerability from error
- name: Audit dependencies
id: audit
run: |
lockguard --format json --min-severity high > audit-report.json
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
- name: Handle vulnerabilities
if: steps.audit.outputs.exit_code == '1'
run: |
echo "::warning::Vulnerabilities found"
jq '.findings[] | {package, severity, advisory_id, link}' audit-report.json
- name: Handle errors
if: steps.audit.outputs.exit_code == '2'
run: |
echo "::error::Security audit failed — check infrastructure"
exit 1
Scheduled daily check
Run a daily audit to catch newly published advisories:
name: Daily security audit
on:
schedule:
- cron: '0 8 * * *'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install lockguard
run: curl -fsSL https://github.com/alinaqi2000/lockguard/releases/latest/download/install.sh | sh
- name: Audit
run: lockguard --format json --min-severity medium
GitLab CI
security_audit:
stage: test
image: rust:latest
before_script:
- cargo install lockguard
script:
- lockguard --format json --min-severity high > audit-report.json
artifacts:
paths:
- audit-report.json
when: always
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Jenkins
pipeline {
agent any
stages {
stage('Security Audit') {
steps {
sh '''
curl -fsSL https://github.com/alinaqi2000/lockguard/releases/latest/download/install.sh | sh
lockguard --format json --min-severity high > audit-report.json
'''
}
post {
always {
archiveArtifacts artifacts: 'audit-report.json'
}
failure {
echo 'Security audit found vulnerabilities or failed'
}
}
}
}
}
Generic shell
For any CI that runs shell commands:
# Install
curl -fsSL https://github.com/alinaqi2000/lockguard/releases/latest/download/install.sh | sh
# Audit — exit 1 if vulnerabilities found
lockguard --format json --min-severity high > audit-report.json
code=$?
if [ $code -eq 0 ]; then
echo "No vulnerabilities found"
elif [ $code -eq 1 ]; then
echo "Vulnerabilities found — see audit-report.json"
jq '.findings[] | .package + " " + .severity' audit-report.json
exit 1
else
echo "Audit failed (exit code $code)"
exit 1
fi
Tips
- Use
--format jsonwhen capturing output programmatically. Text format is for humans. - Use
--min-severityto control what blocks the pipeline. A common policy: block onhighandcritical, reportmediumandlowas warnings. - Cache the binary if your CI supports caching. The install script downloads it each time otherwise.
- Don't parse stderr for results. All report data is on stdout. stderr contains only progress and warnings.
- Pin a version in production CI to avoid surprises from new releases. Replace
latestwith a specific tag likev0.1.4.
Network and Privacy
LockGuard makes network requests to Packagist to fetch security advisory data. This chapter documents exactly what is sent, what is stored, and what is not.
What is sent
When you run LockGuard, it sends HTTP GET requests to:
https://packagist.org/api/security-advisories/?packages[]=vendor/package&packages[]=...
The only data transmitted is package names from your composer.lock. For example:
packages[]=monolog/monolog
packages[]=symfony/console
packages[]=laravel/framework
Package names are sent in batches of 15 per request, sorted alphabetically for deterministic ordering.
What is not sent
LockGuard does not transmit:
- Your
composer.lockfile contents - Package versions (only names are sent to query advisories)
- Project name or path
- Your name, email, or any identifying information
- Credentials or authentication tokens
- Environment variables
- File system paths
- Any data about packages not in your lock file
HTTP behavior
- Method: GET
- Endpoint:
https://packagist.org/api/security-advisories/ - User-Agent:
lockguard/0.1.0(identifies the client to Packagist, as their API guidelines request) - Authentication: none — the public endpoint requires no credentials
- Timeouts: 10 second connect, 30 second request
- Retries: up to 2 retries with exponential backoff (2s, 4s) for transient errors (429, 502, 503, 504)
- Concurrency: sequential — one request at a time, no parallel requests
- TLS: uses rustls, no system OpenSSL dependency
What is stored
LockGuard stores nothing on disk. There is no cache, no log file, no config file, no database. Each run is independent and stateless.
What Packagist sees
Packagist sees that someone at your IP address queried advisory data for specific package names. This is the same information Packagist receives when Composer itself checks for updates. The query pattern is standard API usage.
Offline usage
LockGuard requires network access to function. It cannot run offline because advisory data must be fetched from Packagist. If the network is unavailable, the tool exits with code 2 and an error message on stderr.
Privacy summary
| Data | Sent to Packagist | Stored locally |
|---|---|---|
| Package names | yes | no |
| Package versions | no | no |
| Lock file contents | no | no |
| Project path | no | no |
| User identity | no | no |
| Audit results | no | no |
| Credentials | no | no |
Building from Source
Prerequisites
- Rust (stable toolchain)
- Git
Clone and build
git clone https://github.com/alinaqi2000/lockguard.git
cd lockguard
cargo build --release
The binary is at target/release/lockguard.
Quality gates
Before submitting changes, all four gates must pass:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all
cargo build --release
Running tests
Tests use a local mock HTTP server (wiremock). No live Packagist requests are made during testing.
cargo test --all
The test suite contains 99 tests:
- 89 unit tests (CLI, lock parsing, Packagist DTOs, HTTP client, version matching, severity, filtering, rendering)
- 10 integration tests (full pipeline with mock server: clean, vulnerable, empty, malformed, missing, conflict, API error, JSON, severity filtering)
Test fixtures
Fixtures live in tests/fixtures/:
tests/fixtures/
├── locks/
│ ├── clean.lock
│ ├── vulnerable.lock
│ ├── empty.lock
│ ├── malformed.lock
│ └── duplicate-conflict.lock
└── packagist/
├── clean.json
└── vulnerable.json
These are synthetic files with fake package names. No real project data or sensitive information is included.
Project structure
src/
├── main.rs process boundary: parse CLI, run, exit
├── lib.rs orchestration: read lock, fetch advisories, audit, render
├── cli.rs clap argument parsing and value enums
├── error.rs typed error taxonomy
├── lock.rs composer.lock DTOs, parsing, normalization
├── packagist.rs HTTP client, wire DTOs, batching, retry
├── audit.rs version matching, severity, filtering, result model
└── report.rs text and JSON rendering
Dependency direction
main → cli + lib orchestration
lib → lock + packagist + audit + report
report → audit result model (read-only)
packagist → transport DTOs only
audit → domain types + composer-semver
lock → lock-file DTOs only
Rules enforced by module separation:
reportdoes not call Packagist or parse constraints.packagistdoes not choose exit codes or render user output.lockdoes not silently discard malformed required fields.maindoes not contain domain matching logic.- Transport casing and nullable fields are converted once at the API boundary.
Dependencies
| Crate | Version | Purpose | License |
|---|---|---|---|
| clap | 4 | CLI argument parsing | MIT/Apache-2.0 |
| serde | 1 | Serialization framework | MIT/Apache-2.0 |
| serde_json | 1 | JSON parsing and output | MIT/Apache-2.0 |
| reqwest | 0.12 | HTTP client (rustls) | MIT/Apache-2.0 |
| tokio | 1 | Async runtime | MIT |
| thiserror | 2 | Error derive macro | MIT/Apache-2.0 |
| composer-semver | 0.2 | Composer version constraint matching | EUPL-1.2 |
| wiremock | 0.6 | Mock HTTP server (dev only) | MIT |
Releasing
Releases are triggered by pushing a version tag:
# 1. Bump version in Cargo.toml
# 2. Update Cargo.lock
cargo check
# 3. Commit
git add -A
git commit -m "chore: bump version to 0.2.0"
# 4. Tag and push
git tag v0.2.0
git push origin main v0.2.0
The tag push triggers two GitHub Actions workflows:
- publish.yml — publishes the crate to crates.io
- release.yml — builds Linux binaries (.tar.gz, .deb, .rpm) and attaches them to a GitHub Release
No manual publishing steps are required.
Architecture
Design goals
LockGuard is built around six principles that shape every implementation decision:
| Principle | What it means in practice |
|---|---|
| Fast | One reusable HTTP client, bounded batches (15 packages), explicit timeouts (10s connect, 30s request), minimal dependency features. |
| Standalone | No PHP, Composer, database, daemon, or runtime service required. The binary is self-contained. |
| Deterministic | Identical input produces identical output, ordering, counts, and exit code. No randomness, no timestamps in output. |
| CI-friendly | Stable JSON schema, clean stdout, diagnostics on stderr, documented exit codes, no interactive prompts. |
| Actionable | Every finding shows package, version, advisory ID, severity, affected range, and a link to full details. |
| Small scope | Lock parsing, advisory lookup, matching, filtering, reporting. Nothing else. |
Module layout
src/
├── main.rs process boundary
├── lib.rs orchestration
├── cli.rs argument parsing
├── error.rs error taxonomy
├── lock.rs lock file parsing
├── packagist.rs HTTP client + DTOs
├── audit.rs matching + filtering
└── report.rs rendering
Each module has a single responsibility and a clear boundary with its neighbors.
Data flow
CLI arguments (clap)
│
▼
read composer.lock ──► lock::parse_lock_file
│ │
│ ▼
│ packages + packages-dev
│ deduplicate, sort, validate
│
▼
fetch advisories ──► packagist::Client::fetch_advisories
│ │
│ ├── batch into groups of 15
│ ├── sequential HTTP GET requests
│ ├── retry transient errors (429/502/503/504)
│ └── merge responses
│
▼
match versions ──► audit::run_audit
│ │
│ ├── for each advisory, test installed version
│ │ against affected_versions constraint
│ ├── normalize severity
│ ├── filter by --min-severity threshold
│ └── sort findings
│
▼
render output ──► report::render_text | report::render_json
│
▼
exit code (0 | 1 | 2)
Version matching
The audit engine uses the composer-semver crate to evaluate whether an installed version falls within an advisory's affectedVersions constraint string.
This crate is a Rust port of PHP's Composer semver library. It handles:
- Caret ranges:
^1.2.3→>=1.2.3, <2.0.0 - Tilde ranges:
~1.2.3→>=1.2.3, <1.3.0 - Wildcards:
1.2.*→>=1.2.0, <1.3.0 - Comparison operators:
>=,>,<,<=,= - AND (comma or space):
>=1.0.0,<2.0.0 - OR (double pipe):
>=1.0.0,<2.0.0 || >=3.0.0,<4.0.0 - Hyphen ranges:
1.0.0 - 2.0.0 - Stability flags:
@stable,@dev - Prerelease versions:
1.0.0-beta1 vprefix:v1.2.3is equivalent to1.2.3- Dev branches:
dev-main,dev-master
Using a dedicated crate rather than an ad-hoc parser ensures matching behavior is consistent with Composer itself.
Batching
Package names are sent to Packagist in batches of 15 per HTTP request. The batch size was chosen after live testing: 50 packages per request produced URLs long enough to trigger HTTP 502 errors from Packagist's infrastructure. 15 packages keeps URLs short while limiting request count.
Batches are processed sequentially — no concurrent requests. Packagist asks clients to stay at or below 10 concurrent requests, and sequential processing is simpler and deterministic.
Package names are sorted alphabetically before batching. This means the same lock file always produces the same request sequence, regardless of how packages are ordered in the lock file.
Retry behavior
Transient HTTP errors (429, 502, 503, 504) and transport errors (connection reset, timeout) trigger up to 2 retries:
| Attempt | Delay before request |
|---|---|
| 1 (initial) | none |
| 2 (first retry) | 2 seconds |
| 3 (second retry) | 4 seconds |
If all 3 attempts fail, the error is reported and the audit exits with code 2. Non-transient errors (400, 404, 500, malformed JSON) fail immediately without retry.
Retry progress messages appear on stderr:
retrying batch (attempt 2/3) after 2s...
Error taxonomy
Errors are typed at the module boundary and converted once at the API edge:
| Error | Source | Exit code |
|---|---|---|
| Lock file not found | lock.rs | 2 |
| Invalid JSON in lock file | lock.rs | 2 |
| Missing required fields | lock.rs | 2 |
| Conflicting versions | lock.rs | 2 |
| HTTP client build failure | packagist.rs | 2 |
| HTTP request failure (after retries) | packagist.rs | 2 |
| Non-success HTTP status (after retries) | packagist.rs | 2 |
| Malformed API response | packagist.rs | 2 |
| Report rendering failure | report.rs | 2 |
Errors include context in the message — the file path, the URL, or the HTTP status code — so the user can diagnose the problem without reading source code.
Testing strategy
- Unit tests live in each module under
#[cfg(test)] mod tests. They test individual functions in isolation. - Integration tests live in
tests/integration.rs. They spin up a mock HTTP server with wiremock and test the full pipeline end-to-end. - No live Packagist calls in the test suite. All HTTP responses are mocked.
- Fixtures in
tests/fixtures/provide synthetic lock files and API responses with fake package names.
The test suite covers:
- CLI argument parsing (defaults, custom paths, invalid values)
- Lock file parsing (valid, invalid, missing, extra fields, duplicates, conflicts)
- Packagist DTO deserialization (all field types, nullables)
- HTTP client (success, batching, dedup, sorting, status errors, malformed JSON, retries)
- Version matching (caret, tilde, wildcard, OR, hyphen, v-prefix, boundaries, multiple advisories)
- Severity normalization and threshold filtering (all combinations)
- Text and JSON rendering (empty, populated, optional fields, null policy, ordering)
- Integration scenarios (clean, vulnerable, empty, malformed, missing, conflict, API error, JSON, severity filter)
Troubleshooting
Common issues and solutions
error: failed to read lock file composer.lock: No such file or directory
You are running LockGuard in a directory without a composer.lock file. Either navigate to the project directory or specify the path:
lockguard --lock /path/to/project/composer.lock
error: failed to parse lock file: invalid JSON
The lock file is not valid JSON. This usually means the file is corrupted or was manually edited. Regenerate it:
composer update --lock
Or restore it from version control:
git checkout composer.lock
error: duplicate package 'vendor/pkg' with conflicting versions
The same package appears in both packages and packages-dev with different versions. This is a lock file inconsistency — Composer itself should not produce this. Regenerate the lock file:
rm composer.lock
composer install
error: Packagist API returned status 502
Packagist's infrastructure returned a server error. LockGuard retries these automatically (up to 2 times with backoff), but if all retries fail, the error surfaces. Wait a few minutes and re-run:
lockguard
If the error persists, check Packagist status or their Twitter account for outage announcements.
error: Packagist API returned status 429
You are being rate-limited. This is unlikely with LockGuard's sequential, low-volume request pattern, but can happen if you run many audits in quick succession. Wait a minute and try again.
error: failed to send request: error sending request
A network error occurred — DNS resolution failure, connection refused, or timeout. Check your internet connection and whether packagist.org is reachable:
curl -I https://packagist.org
If you are behind a corporate proxy or firewall, ensure outbound HTTPS to packagist.org is allowed.
lockguard: command not found
The binary is not on your PATH. If you installed via cargo:
# Check where cargo installs binaries
ls ~/.cargo/bin/lockguard
# Add cargo bin to PATH (add to ~/.bashrc or ~/.zshrc for persistence)
export PATH="$HOME/.cargo/bin:$PATH"
If you installed via the install script or package manager, verify the binary exists:
which lockguard
ls /usr/local/bin/lockguard
ls /usr/bin/lockguard
No vulnerabilities found but you expected some
Possible causes:
-
The advisory was filtered by severity. Try running without
--min-severity:lockguard -
The installed version is outside the affected range. Check the advisory's
affected_versionsfield — your version may already be patched. -
Packagist has no advisory for the package. Not all packages have published advisories. The absence of findings means Packagist has no matching advisory data, not that the package is guaranteed safe.
-
The package name casing differs. LockGuard normalizes package names to lowercase before querying, so this should not be an issue. If you suspect it, check the lock file for unusual formatting.
warning: no advisory data for 'vendor/pkg' — coverage unknown
This is an informational message on stderr, not an error. It means Packagist did not return any entry for that package name. This could mean:
- The package has no published advisories (good).
- The package name is not in Packagist's advisory database (neutral — no data either way).
The message is printed so you know the tool queried for the package but got no response. It does not affect the exit code or the report.
JSON output is not valid JSON
This should not happen — stdout contains exactly one JSON document. If you see invalid JSON:
-
Make sure you are not mixing text and JSON output. Use
--format jsonexplicitly. -
Check that stderr is not being captured alongside stdout. Redirect stderr separately:
lockguard --format json 2>errors.txt >report.json -
If the JSON is still invalid, report a bug.
The install script fails with permission errors
The one-liner install script needs sudo to write to /usr/local/bin. If you cannot use sudo, install to a user-writable directory:
curl -fsSL https://github.com/alinaqi2000/lockguard/releases/latest/download/lockguard-x86_64-unknown-linux-gnu.tar.gz | tar xz -C ~/.local/bin
chmod +x ~/.local/bin/lockguard
Make sure ~/.local/bin is on your PATH.
LockGuard is slow on a large lock file
LockGuard processes batches sequentially. A lock file with 150 packages results in about 10 requests. Each request takes a fraction of a second under normal conditions, so the total time is typically 2-5 seconds.
If it is significantly slower:
- Check your network latency to
packagist.org. - Check if retries are happening (look for
retrying batchmessages on stderr). - Packagist may be under load — try again later.
Reporting bugs
If you encounter a bug, please open an issue with:
- The LockGuard version (
lockguard --version). - The command you ran.
- The full output (stdout and stderr).
- The exit code (
echo $?). - Your operating system and architecture.
- The
composer.lockfile or a redacted version that reproduces the issue.
Do not include credentials, private package names, or any data you consider sensitive. Package names from composer.lock are sufficient to reproduce most issues.
Changelog
All notable changes to LockGuard are documented here. Versions follow Semantic Versioning.
v0.1.4 — 2026-08-31
Fixed
- Reduced batch size from 50 to 15 packages per request. The previous batch size produced URLs that triggered HTTP 502 errors from Packagist's infrastructure on larger lock files.
- Added retry with exponential backoff (2 retries, 2s/4s delays) for transient HTTP errors (429, 502, 503, 504). Non-transient errors fail immediately.
- Fixed install script permission errors by using a temporary directory and
install -m 755instead of extracting directly to/tmpand usingmv+chmod.
v0.1.3 — 2026-08-31
Fixed
- Fixed the one-liner install script not being included as a release asset, causing a 404 when users ran
curl -fsSL .../install.sh | sh.
v0.1.2 — 2026-08-31
Fixed
- Added
packaging/install.shto release assets so the one-liner install works.
v0.1.1 — 2026-08-29
Fixed
- Corrected repository URLs in
Cargo.tomlandREADME.mdfromgithub.com/alinaqitogithub.com/alinaqi2000.
v0.1.0 — 2026-08-29
Added
- Initial release.
- Audit
composer.lockfiles against the Packagist Security Advisories API. - Composer-compatible version constraint matching via the
composer-semvercrate. - Text and JSON output formats with stable schema.
- Severity normalization (critical, high, medium, low, unknown) and threshold filtering.
- Documented exit codes: 0 (clean), 1 (vulnerabilities found), 2 (operational error).
- Both
packagesandpackages-devsections audited with deduplication. - Bounded sequential batching (15 packages per request).
- Explicit connect (10s) and request (30s) timeouts.
- 99 tests (89 unit + 10 integration) with mocked HTTP server.
- CI workflow: fmt, clippy, test, release build.
- crates.io publication workflow triggered by version tags.
- Linux binary release workflow: x86_64 and aarch64 tar.gz, .deb, .rpm with SHA256 checksums.
- Install script for systems without a package manager.
- AUR PKGBUILD for Arch Linux.