Security
Supported version: 1.0.x. For the formal security policy and vulnerability reporting, see SECURITY.md.
Threat model
licit operates as a local audit tool. Its attack surface is limited, but there are risks to consider:
Identified threats
| Threat | Severity | Mitigation |
|---|---|---|
| Provenance store manipulation | High | HMAC-SHA256 signing, Merkle tree integrity |
| Signing key exposure | High | .licit/.signing-key in .gitignore, 600 permissions |
| Sensitive data in FRIA | Medium | .gitignore for fria-data.json, do not push to public repos |
| Contributor data in provenance | Medium | .gitignore for provenance.jsonl |
| Injection via malicious YAML | Low | Exclusive use of yaml.safe_load() (not yaml.load()) |
| Malicious SARIF/JSON | Low | json.loads only, isinstance on all fields |
| Compromised dependencies | Medium | Periodic auditing, minimum version pinning |
| Code execution via configs | Low | No code is executed from configs; only data is parsed |
| Git command injection | Low | subprocess.run with list (no shell=True), timeouts |
What licit does NOT do
- Does not execute arbitrary code from the files it analyzes.
- Does not send data to external servers. Everything is processed locally. No telemetry.
- Does not require elevated permissions. Operates with user permissions.
- Does not modify the source code of the analyzed project.
- Does not store credentials. Signing keys are managed by the user.
- Connectors are read-only. The architect and vigil connectors only read files — they do not execute tools or modify data.
Cryptographic signing (provenance)
HMAC-SHA256
When provenance signing is enabled (provenance.sign: true), each record is signed with HMAC-SHA256:
signature = HMAC-SHA256(key, canonical_json(record))
Configuration:
provenance:
sign: true
# Optional: external key path (defaults to .licit/.signing-key in the project)
sign_key_path: ~/.licit/signing-key
Manual key generation:
# Generate a 256-bit key
python3.12 -c "import secrets; print(secrets.token_hex(32))" > ~/.licit/signing-key
chmod 600 ~/.licit/signing-key
Note: If
sign_key_pathis not specified, licit auto-generates a key at.licit/.signing-keywithin the project. If you prefer to keep the key outside the project, usesign_key_pathwith an external path.
Attestation (Merkle tree)
licit implements a Merkle tree over provenance records to detect manipulation:
root_hash
/ \
hash_01 hash_23
/ \ / \
hash_0 hash_1 hash_2 hash_3
| | | |
rec_0 rec_1 rec_2 rec_3
Any modification of a record invalidates the hash chain from that record up to the root.
Implementation:
- Each record is serialized as canonical JSON (
sort_keys=True, default=str) - SHA256 is computed for each record → tree leaves
- Hash pairs are concatenated and re-hashed up to the root
- Odd records: the last one is duplicated to complete the pair
- Individual verification uses
hmac.compare_digest(timing-safe)
from licit.provenance.attestation import ProvenanceAttestor
attestor = ProvenanceAttestor() # Auto-generates key at .licit/.signing-key
# Sign an individual record
sig = attestor.sign_record({"file": "app.py", "source": "ai"})
# Verify integrity
assert attestor.verify_record({"file": "app.py", "source": "ai"}, sig)
# Sign batch with Merkle tree
root = attestor.sign_batch([record1, record2, record3])
Key management
The signing key is resolved in this order:
- Explicit path (
sign_key_pathin config) - Local fallback (
.licit/.signing-keyin the project) - Auto-generation (32 random bytes with
os.urandom(32))
All filesystem accesses are protected with try/except OSError.
Data protection
Sensitive data generated by licit
| File | Sensitivity | Recommendation |
|---|---|---|
.licit.yaml | Low | Commit to repo |
.licit/provenance.jsonl | Medium | Do not commit (contains contributor info) |
.licit/fria-data.json | High | Do not commit (rights impact data) |
.licit/fria-report.md | Medium | Selective commit |
.licit/annex-iv.md | Low | Commit to repo |
.licit/changelog.md | Low | Commit to repo |
.licit/reports/* | Low | Commit to repo |
| Signing key | Critical | Never commit, 600 permissions |
Recommended .gitignore
# licit — sensitive data
.licit/provenance.jsonl
.licit/fria-data.json
# licit — signing key (if stored in the project)
.licit/.signing-key
*.key
# licit — generated reports (optional, can be committed)
# .licit/reports/
Dependencies
Dependency audit
licit uses 6 runtime dependencies, all widely adopted:
| Dependency | Min. version | Purpose | Maintainer |
|---|---|---|---|
| click | 8.1+ | CLI framework | Pallets |
| pydantic | 2.0+ | Config validation | Samuel Colvin |
| structlog | 24.1+ | Structured logging | Hynek Schlawack |
| pyyaml | 6.0+ | YAML parsing | YAML org |
| jinja2 | 3.1+ | Report templates | Pallets |
| cryptography | 42.0+ | HMAC-SHA256 | PyCA |
Recommendations
-
Pin versions in production: Use a
requirements.txtorpip-compileto lock exact versions. -
Audit regularly:
pip audit # Search for known vulnerabilities pip install pip-audit && pip-audit # Alternative -
Verify hashes:
pip install --require-hashes -r requirements.txt
Safe file parsing
YAML
licit always uses yaml.safe_load() to parse YAML. Never yaml.load() (which allows arbitrary Python code execution).
# Correct (what licit does)
data = yaml.safe_load(f.read())
# NEVER (vulnerable to code execution)
# data = yaml.load(f.read(), Loader=yaml.FullLoader)
JSON
For SARIF and other JSON files, standard json.load() is used, which is safe by design.
Agent configuration files
Files like CLAUDE.md, .cursorrules, AGENTS.md are read as plain text. licit does not interpret or execute their content — it only analyzes them to detect changes and extract metadata.
External process execution
licit executes git commands via subprocess.run() with the following protections:
capture_output=True— stdout/stderr captured, not displayed directly.text=True— Automatic UTF-8 decoding.- No
shell=True— Arguments are passed as a list, not a string, preventing command injection. timeout=30— Explicit 30-second timeout ongit logto prevent hangs on massive repos (10 seconds forgit showand existence checks).subprocess.TimeoutExpiredcaught — returns empty list without crashing.- Explicit
check=False— on allsubprocess.runcalls in provenance and changelog (does not raise exception on returncode != 0). - Size guard (changelog):
ConfigWatcher._MAX_CONTENT_BYTES = 1_048_576— discardsgit showcontent larger than 1 MB to prevent OOM with accidentally tracked binary files.
# How licit executes git commands
result = subprocess.run(
["git", "rev-list", "--count", "HEAD"],
capture_output=True,
text=True,
)
Vulnerability reporting
If you find a security vulnerability in licit:
- Do not open a public issue.
- Send an email to security@licit.dev (or open a private advisory on GitHub) with:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if you have one)
- You will receive a confirmation within 48 hours.
- A fix will be published within a maximum of 7 days for critical issues.
See SECURITY.md at the project root for the full policy.