Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/sh
set -e

echo "Running cargo fmt check..."
cargo fmt -- --check

echo "Running cargo clippy..."
cargo clippy -- -D warnings

echo "Running cargo test..."
cargo test

echo "Pre-commit checks passed."
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- uses: Swatinem/rust-cache@v2

- name: Check formatting
run: cargo fmt -- --check

- name: Clippy
run: cargo clippy -- -D warnings

- name: Tests
run: cargo test
56 changes: 56 additions & 0 deletions .github/workflows/release-plz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Release-plz

on:
push:
branches: [main]

concurrency:
group: release-plz
cancel-in-progress: false

permissions:
contents: write
pull-requests: write

jobs:
release-plz-release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
Comment thread
boorad marked this conversation as resolved.
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2

- name: Release
uses: release-plz/action@v0.5
with:
command: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

release-plz-pr:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2

- name: Release PR
uses: release-plz/action@v0.5
with:
command: release-pr
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
*.swo
.DS_Store
.env
.claude/settings.local.json
.claude/worktrees/
60 changes: 60 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Zift

Static analysis tool that scans codebases for embedded authorization logic and generates Rego policies for OPA.

## Setup

After cloning, configure the pre-commit hook:

```bash
git config core.hooksPath .githooks
```

## Build & Development

```bash
cargo build
cargo build --release
cargo test
cargo fmt # required before committing
cargo clippy -- -D warnings
```

## Architecture

- **CLI** (`src/cli.rs`): Subcommands — `scan`, `extract`, `report`, `rules`, `init`
- **Scanner** (`src/scanner/`): Tree-sitter AST parsing and pattern matching across languages
- **Rules** (`rules/`): TOML-based pattern definitions with tree-sitter queries and Rego templates
- **Rego** (`src/rego/`): Policy generation from scan findings
- **Output** (`src/output/`): Formatters (JSON, text; SARIF planned)

### Design principles

- Two-pass architecture: structural scan (tree-sitter, fast) then optional semantic scan (LLM-assisted)
- Rules are data (TOML), not code — easy to add new patterns without touching Rust
- Same finding schema for both passes

### Language support

- v0.1: TypeScript, JavaScript (Java in progress)
- v0.2: Python, Go
- v0.3: C#, Kotlin, Ruby, PHP

## Conventional Commits & Versioning

Uses release-plz for automated version bumping and changelog generation.

Trigger prefixes (cause version bump):
- `feat:` — new feature (minor)
- `fix:` — bug fix (patch)
- `refactor:` — code refactoring (patch)
- `perf:` — performance improvement (patch)

Skipped prefixes (no version bump):
- `docs:`, `test:`, `ci:`, `chore:`, `style:`, `build:`

PR titles must use a conventional commit prefix.

## Hard Rules

- Never include `Co-Authored-By` in commit messages
23 changes: 23 additions & 0 deletions release-plz.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[workspace]
release_commits = "^(feat|fix|refactor|perf)[(!:]"
publish = false
semver_check = false
git_only = true
git_tag_enable = true
git_release_enable = true

[changelog]
protect_breaking_commits = true

commit_parsers = [
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^refactor", group = "Changed" },
{ message = "^perf", group = "Performance" },
{ message = "^doc", skip = true },
{ message = "^test", skip = true },
{ message = "^ci", skip = true },
{ message = "^chore", skip = true },
{ message = "^style", skip = true },
{ message = "^build", skip = true },
]
3 changes: 3 additions & 0 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
12 changes: 9 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,15 @@ mod tests {

#[test]
fn report_subcommand() {
let cli =
Cli::try_parse_from(["zift", "report", "--input", "findings.json", "-f", "markdown"])
.unwrap();
let cli = Cli::try_parse_from([
"zift",
"report",
"--input",
"findings.json",
"-f",
"markdown",
])
.unwrap();
assert!(matches!(cli.command, Some(Command::Report(_))));
}

Expand Down
24 changes: 17 additions & 7 deletions src/commands/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ pub fn execute(args: RulesArgs, config: ZiftConfig) -> Result<()> {
println!("No pattern rules loaded.");
return Ok(());
}
println!("{:<35} {:<15} {:<10} Languages", "ID", "Category", "Confidence");
println!(
"{:<35} {:<15} {:<10} Languages",
"ID", "Category", "Confidence"
);
println!("{}", "-".repeat(80));
for rule in &loaded {
let langs: Vec<String> = rule.languages.iter().map(|l| l.to_string()).collect();
Expand All @@ -40,9 +43,7 @@ pub fn execute(args: RulesArgs, config: ZiftConfig) -> Result<()> {
};
for &is_tsx_jsx in variants {
let ts_lang = ts_parser::get_language(*lang, is_tsx_jsx)?;
if let Err(e) =
tree_sitter::Query::new(&ts_lang, &rule.query_source)
{
if let Err(e) = tree_sitter::Query::new(&ts_lang, &rule.query_source) {
let suffix = if is_tsx_jsx { "/tsx" } else { "" };
eprintln!("FAIL {} ({lang}{suffix}): query: {e}", rule.id);
errors += 1;
Expand Down Expand Up @@ -97,7 +98,10 @@ pub fn execute(args: RulesArgs, config: ZiftConfig) -> Result<()> {
Ok(t) => t,
Err(e) => {
let suffix = if is_tsx_jsx { "/tsx" } else { "" };
eprintln!("FAIL {}[{i}] ({lang}{suffix}): parse error: {e}", rule.id);
eprintln!(
"FAIL {}[{i}] ({lang}{suffix}): parse error: {e}",
rule.id
);
failed += 1;
continue;
}
Expand All @@ -107,7 +111,10 @@ pub fn execute(args: RulesArgs, config: ZiftConfig) -> Result<()> {
Ok(c) => c,
Err(e) => {
let suffix = if is_tsx_jsx { "/tsx" } else { "" };
eprintln!("FAIL {}[{i}] ({lang}{suffix}): compile error: {e}", rule.id);
eprintln!(
"FAIL {}[{i}] ({lang}{suffix}): compile error: {e}",
rule.id
);
failed += 1;
continue;
}
Expand All @@ -123,7 +130,10 @@ pub fn execute(args: RulesArgs, config: ZiftConfig) -> Result<()> {
Ok(f) => f,
Err(e) => {
let suffix = if is_tsx_jsx { "/tsx" } else { "" };
eprintln!("FAIL {}[{i}] ({lang}{suffix}): query error: {e}", rule.id);
eprintln!(
"FAIL {}[{i}] ({lang}{suffix}): query error: {e}",
rule.id
);
failed += 1;
continue;
}
Expand Down
22 changes: 15 additions & 7 deletions src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ pub fn execute(args: ScanArgs, config: ZiftConfig) -> Result<()> {
tracing::info!("scanning {}", path.display());

if args.deep {
eprintln!("warning: --deep (LLM-assisted) is not yet implemented, running structural scan only");
eprintln!(
"warning: --deep (LLM-assisted) is not yet implemented, running structural scan only"
);
}

let loaded_rules = rules::load_rules(args.rules_dir.as_deref(), &config)?;
Expand All @@ -37,12 +39,18 @@ pub fn execute(args: ScanArgs, config: ZiftConfig) -> Result<()> {
};

match args.format {
OutputFormat::Text => {
output::text::print(&result.findings, &path, result.enforcement_points, &mut writer)?
}
OutputFormat::Json => {
output::json::print(&result.findings, &path, result.enforcement_points, &mut writer)?
}
OutputFormat::Text => output::text::print(
&result.findings,
&path,
result.enforcement_points,
&mut writer,
)?,
OutputFormat::Json => output::json::print(
&result.findings,
&path,
result.enforcement_points,
&mut writer,
)?,
OutputFormat::Sarif => unreachable!("pre-checked above"),
}

Expand Down
9 changes: 4 additions & 5 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,10 @@ pub fn load_config(path: &Path) -> Result<ZiftConfig> {
}

let content = std::fs::read_to_string(path)?;
let config: ZiftConfig =
toml::from_str(&content).map_err(|e| ZiftError::ConfigParse {
path: path.to_path_buf(),
source: e,
})?;
let config: ZiftConfig = toml::from_str(&content).map_err(|e| ZiftError::ConfigParse {
path: path.to_path_buf(),
source: e,
})?;

tracing::debug!("loaded config from {}", path.display());
Ok(config)
Expand Down
4 changes: 1 addition & 3 deletions src/output/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ pub fn print(
*by_category
.entry(f.category.to_string().to_lowercase())
.or_default() += 1;
*by_confidence
.entry(f.confidence.to_string())
.or_default() += 1;
*by_confidence.entry(f.confidence.to_string()).or_default() += 1;
files.insert(&f.file);
}

Expand Down
27 changes: 21 additions & 6 deletions src/output/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,18 @@ pub fn print(
}

// Summary
let high = findings.iter().filter(|f| f.confidence == crate::types::Confidence::High).count();
let medium = findings.iter().filter(|f| f.confidence == crate::types::Confidence::Medium).count();
let low = findings.iter().filter(|f| f.confidence == crate::types::Confidence::Low).count();
let high = findings
.iter()
.filter(|f| f.confidence == crate::types::Confidence::High)
.count();
let medium = findings
.iter()
.filter(|f| f.confidence == crate::types::Confidence::Medium)
.count();
let low = findings
.iter()
.filter(|f| f.confidence == crate::types::Confidence::Low)
.count();
let file_count = {
let mut files = std::collections::HashSet::new();
for f in findings {
Expand All @@ -68,9 +77,15 @@ pub fn print(
)?;

let mut parts = Vec::new();
if high > 0 { parts.push(format!("{high} high")); }
if medium > 0 { parts.push(format!("{medium} medium")); }
if low > 0 { parts.push(format!("{low} low")); }
if high > 0 {
parts.push(format!("{high} high"));
}
if medium > 0 {
parts.push(format!("{medium} medium"));
}
if low > 0 {
parts.push(format!("{low} low"));
}
if !parts.is_empty() {
write!(writer, " ({})", parts.join(", "))?;
}
Expand Down
6 changes: 2 additions & 4 deletions src/rego/grouping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,8 @@ mod tests {

#[test]
fn derive_output_path() {
let path = super::derive_output_path(
Path::new("src/api/orders.ts"),
Path::new("./policies"),
);
let path =
super::derive_output_path(Path::new("src/api/orders.ts"), Path::new("./policies"));
assert_eq!(path, PathBuf::from("./policies/api/orders.rego"));
}

Expand Down
Loading
Loading