zairo 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zairo-0.1.0/LICENSE +21 -0
- zairo-0.1.0/PKG-INFO +75 -0
- zairo-0.1.0/README.md +46 -0
- zairo-0.1.0/pyproject.toml +54 -0
- zairo-0.1.0/setup.cfg +4 -0
- zairo-0.1.0/src/zairo/__init__.py +1 -0
- zairo-0.1.0/src/zairo/__main__.py +4 -0
- zairo-0.1.0/src/zairo/_util.py +12 -0
- zairo-0.1.0/src/zairo/analyzer.py +121 -0
- zairo-0.1.0/src/zairo/cli.py +108 -0
- zairo-0.1.0/src/zairo/git_utils.py +131 -0
- zairo-0.1.0/src/zairo/llm_scanner.py +507 -0
- zairo-0.1.0/src/zairo/reporter.py +197 -0
- zairo-0.1.0/src/zairo.egg-info/PKG-INFO +75 -0
- zairo-0.1.0/src/zairo.egg-info/SOURCES.txt +21 -0
- zairo-0.1.0/src/zairo.egg-info/dependency_links.txt +1 -0
- zairo-0.1.0/src/zairo.egg-info/entry_points.txt +2 -0
- zairo-0.1.0/src/zairo.egg-info/requires.txt +9 -0
- zairo-0.1.0/src/zairo.egg-info/top_level.txt +1 -0
- zairo-0.1.0/tests/test_analyzer.py +27 -0
- zairo-0.1.0/tests/test_cli.py +20 -0
- zairo-0.1.0/tests/test_git_utils.py +31 -0
- zairo-0.1.0/tests/test_reporter.py +28 -0
zairo-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Avantika (@iamavu)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
zairo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zairo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Git-diff-aware security impact analysis: builds a dependency subgraph around changed code and optionally scans it for vulnerabilities with an LLM.
|
|
5
|
+
Author-email: iamavu <imailavantika@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/iamavu/zairo
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: typer>=0.27
|
|
21
|
+
Requires-Dist: rich>=15.0
|
|
22
|
+
Requires-Dist: jinja2>=3.1
|
|
23
|
+
Requires-Dist: litellm>=1.98
|
|
24
|
+
Requires-Dist: trailmark>=0.5
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# zairo
|
|
31
|
+
|
|
32
|
+
Git-diff-aware security impact analysis. `zairo` diffs a repository, builds a
|
|
33
|
+
dependency subgraph (via [Trailmark](https://pypi.org/project/trailmark/))
|
|
34
|
+
around whatever changed, and can run an LLM vulnerability scan limited to
|
|
35
|
+
just that changed code — instead of re-scanning the whole codebase on every
|
|
36
|
+
change.
|
|
37
|
+
|
|
38
|
+
Output is a `report.json` (raw graph data) and a self-contained
|
|
39
|
+
`report.html` (interactive dependency graph viewer).
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install zairo
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For local development:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m venv venv
|
|
51
|
+
source venv/bin/activate
|
|
52
|
+
pip install -e ".[dev]"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Analyze uncommitted changes in a repo
|
|
59
|
+
zairo /path/to/repo
|
|
60
|
+
|
|
61
|
+
# Diff two refs, traverse 2 hops out from changed nodes, run an LLM scan
|
|
62
|
+
zairo /path/to/repo --base main --target HEAD --depth 2 --llm
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Run `zairo --help` for the full option list.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pytest
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT — see [LICENSE](LICENSE).
|
zairo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# zairo
|
|
2
|
+
|
|
3
|
+
Git-diff-aware security impact analysis. `zairo` diffs a repository, builds a
|
|
4
|
+
dependency subgraph (via [Trailmark](https://pypi.org/project/trailmark/))
|
|
5
|
+
around whatever changed, and can run an LLM vulnerability scan limited to
|
|
6
|
+
just that changed code — instead of re-scanning the whole codebase on every
|
|
7
|
+
change.
|
|
8
|
+
|
|
9
|
+
Output is a `report.json` (raw graph data) and a self-contained
|
|
10
|
+
`report.html` (interactive dependency graph viewer).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install zairo
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For local development:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m venv venv
|
|
22
|
+
source venv/bin/activate
|
|
23
|
+
pip install -e ".[dev]"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Analyze uncommitted changes in a repo
|
|
30
|
+
zairo /path/to/repo
|
|
31
|
+
|
|
32
|
+
# Diff two refs, traverse 2 hops out from changed nodes, run an LLM scan
|
|
33
|
+
zairo /path/to/repo --base main --target HEAD --depth 2 --llm
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Run `zairo --help` for the full option list.
|
|
37
|
+
|
|
38
|
+
## Development
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pytest
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zairo"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Git-diff-aware security impact analysis: builds a dependency subgraph around changed code and optionally scans it for vulnerabilities with an LLM."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "iamavu", email = "imailavantika@gmail.com" },
|
|
11
|
+
]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"typer>=0.27",
|
|
25
|
+
"rich>=15.0",
|
|
26
|
+
"jinja2>=3.1",
|
|
27
|
+
"litellm>=1.98",
|
|
28
|
+
"trailmark>=0.5",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.0",
|
|
34
|
+
"build>=1.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
zairo = "zairo.cli:main"
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Repository = "https://github.com/iamavu/zairo"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["setuptools>=61.0"]
|
|
45
|
+
build-backend = "setuptools.build_meta"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.dynamic]
|
|
48
|
+
version = { attr = "zairo.__version__" }
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
where = ["src"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
zairo-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def display_name(name: Any, limit: int = 60) -> str:
|
|
5
|
+
"""Collapses a node name to one short line for log display. Some graph
|
|
6
|
+
nodes (e.g. Trailmark misparsing a chained expression like
|
|
7
|
+
`.map(fn).filter(...)`) end up with a "name" that's actually a chunk of
|
|
8
|
+
raw multi-line source text -- printing that verbatim floods the log."""
|
|
9
|
+
text = " ".join(str(name).split())
|
|
10
|
+
if len(text) > limit:
|
|
11
|
+
text = text[:limit - 1] + "…"
|
|
12
|
+
return text
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Callable, Dict, List, Optional, Set, Any
|
|
3
|
+
from trailmark.query.api import QueryEngine
|
|
4
|
+
from .git_utils import get_modified_lines
|
|
5
|
+
from ._util import display_name as _display_name
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def analyze_impact(
|
|
9
|
+
repo_path: str,
|
|
10
|
+
depth: int = 1,
|
|
11
|
+
base: str = None,
|
|
12
|
+
target: str = None,
|
|
13
|
+
language: str = "auto",
|
|
14
|
+
log: Optional[Callable[[str], None]] = None,
|
|
15
|
+
) -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
`repo_path` must already be checked out at the state to be indexed: the
|
|
18
|
+
caller is responsible for pointing it at a worktree checked out to
|
|
19
|
+
`target` when diffing two commits, so that node locations/contents line
|
|
20
|
+
up with the line numbers `git diff base target` reports.
|
|
21
|
+
"""
|
|
22
|
+
log = log or (lambda msg: None)
|
|
23
|
+
|
|
24
|
+
analysis_root = os.path.abspath(repo_path)
|
|
25
|
+
|
|
26
|
+
modified_files_lines = get_modified_lines(analysis_root, base, target, log=log)
|
|
27
|
+
log(f"git diff found {len(modified_files_lines)} modified file(s):")
|
|
28
|
+
for f, lines in modified_files_lines.items():
|
|
29
|
+
log(f" {f}: {len(lines)} line(s) changed -> {sorted(lines.keys())}")
|
|
30
|
+
|
|
31
|
+
# Initialize Trailmark
|
|
32
|
+
log(f"Indexing {analysis_root} with Trailmark (language={language})...")
|
|
33
|
+
engine = QueryEngine.from_directory(analysis_root, language=language)
|
|
34
|
+
total_nodes = len(engine._store._graph.nodes)
|
|
35
|
+
total_edges = len(engine._store._graph.edges)
|
|
36
|
+
log(f"Trailmark graph: {total_nodes} node(s), {total_edges} edge(s)")
|
|
37
|
+
|
|
38
|
+
# 1. Identify seed nodes (modified/added)
|
|
39
|
+
seed_nodes = set()
|
|
40
|
+
node_metadata = {}
|
|
41
|
+
|
|
42
|
+
for node_id, node in engine._store._graph.nodes.items():
|
|
43
|
+
node_metadata[node_id] = {
|
|
44
|
+
"id": node_id,
|
|
45
|
+
"name": getattr(node, 'name', node_id),
|
|
46
|
+
"kind": node.kind.value if hasattr(node, 'kind') and hasattr(node.kind, 'value') else str(getattr(node, 'kind', 'unknown')),
|
|
47
|
+
"file": node.location.file_path if getattr(node, 'location', None) else None,
|
|
48
|
+
"start_line": node.location.start_line if getattr(node, 'location', None) else None,
|
|
49
|
+
"end_line": node.location.end_line if getattr(node, 'location', None) else None,
|
|
50
|
+
"complexity": getattr(node, 'cyclomatic_complexity', 0),
|
|
51
|
+
"status": "unchanged" # default
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if getattr(node, 'location', None) and node.location.file_path in modified_files_lines:
|
|
55
|
+
file_mod_lines = modified_files_lines[node.location.file_path]
|
|
56
|
+
start = node.location.start_line
|
|
57
|
+
end = node.location.end_line
|
|
58
|
+
changed_lines = {ln: text for ln, text in file_mod_lines.items() if start <= ln <= end}
|
|
59
|
+
if changed_lines:
|
|
60
|
+
seed_nodes.add(node_id)
|
|
61
|
+
node_metadata[node_id]["status"] = "modified"
|
|
62
|
+
node_metadata[node_id]["changed_lines"] = changed_lines
|
|
63
|
+
log(f" seed: {_display_name(node_metadata[node_id]['name'])} ({node.location.file_path}:{start}-{end}), {len(changed_lines)} line(s) changed")
|
|
64
|
+
|
|
65
|
+
log(f"Identified {len(seed_nodes)} seed node(s)")
|
|
66
|
+
|
|
67
|
+
# 2. Traverse graph to build subgraph up to `depth`
|
|
68
|
+
subgraph_nodes = set(seed_nodes)
|
|
69
|
+
current_frontier = set(seed_nodes)
|
|
70
|
+
|
|
71
|
+
for hop in range(depth):
|
|
72
|
+
next_frontier = set()
|
|
73
|
+
for edge in engine._store._graph.edges:
|
|
74
|
+
source = edge.source_id
|
|
75
|
+
edge_target = edge.target_id
|
|
76
|
+
|
|
77
|
+
if source in current_frontier and edge_target not in subgraph_nodes:
|
|
78
|
+
next_frontier.add(edge_target)
|
|
79
|
+
subgraph_nodes.add(edge_target)
|
|
80
|
+
elif edge_target in current_frontier and source not in subgraph_nodes:
|
|
81
|
+
next_frontier.add(source)
|
|
82
|
+
subgraph_nodes.add(source)
|
|
83
|
+
|
|
84
|
+
log(f"Hop {hop + 1}/{depth}: added {len(next_frontier)} node(s), frontier now {len(subgraph_nodes)} total")
|
|
85
|
+
current_frontier = next_frontier
|
|
86
|
+
|
|
87
|
+
# Extract edges for subgraph
|
|
88
|
+
final_edges = []
|
|
89
|
+
for edge in engine._store._graph.edges:
|
|
90
|
+
if edge.source_id in subgraph_nodes and edge.target_id in subgraph_nodes:
|
|
91
|
+
final_edges.append({
|
|
92
|
+
"source": edge.source_id,
|
|
93
|
+
"target": edge.target_id,
|
|
94
|
+
"kind": edge.kind.value if hasattr(edge, 'kind') and hasattr(edge.kind, 'value') else str(getattr(edge, 'kind', 'unknown')),
|
|
95
|
+
"confidence": edge.confidence.value if hasattr(edge, 'confidence') and hasattr(edge.confidence, 'value') else "unknown"
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
nodes = []
|
|
99
|
+
for n_id in subgraph_nodes:
|
|
100
|
+
# An edge can reference a node id Trailmark's own graph has no entry
|
|
101
|
+
# for (a dangling/malformed reference -- seen from complex chained
|
|
102
|
+
# expressions like `.map(fn).filter(...)`). The fallback must carry
|
|
103
|
+
# the same fields as a normal node, or downstream code that assumes
|
|
104
|
+
# e.g. 'file' always exists (to read source for LLM context) crashes
|
|
105
|
+
# with a bare KeyError on this one bad node instead of just treating
|
|
106
|
+
# it as having no known location.
|
|
107
|
+
nodes.append(node_metadata.get(n_id, {
|
|
108
|
+
"id": n_id,
|
|
109
|
+
"name": n_id,
|
|
110
|
+
"kind": "unknown",
|
|
111
|
+
"file": None,
|
|
112
|
+
"start_line": None,
|
|
113
|
+
"end_line": None,
|
|
114
|
+
"complexity": 0,
|
|
115
|
+
"status": "unchanged",
|
|
116
|
+
}))
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"nodes": nodes,
|
|
120
|
+
"edges": final_edges
|
|
121
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import typer
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from .analyzer import analyze_impact
|
|
5
|
+
from .reporter import generate_reports
|
|
6
|
+
from .llm_scanner import scan_graph_for_vulnerabilities
|
|
7
|
+
from .git_utils import create_worktree, remove_worktree
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(add_completion=False)
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
@app.command()
|
|
13
|
+
def analyze(
|
|
14
|
+
repo_path: str = typer.Argument(..., help="Path to the git repository"),
|
|
15
|
+
depth: int = typer.Option(1, "--depth", "-d", help="Depth of connections to traverse from changed nodes"),
|
|
16
|
+
output_dir: str = typer.Option("zairo_out", "--output", "-o", help="Output directory for reports"),
|
|
17
|
+
base: str = typer.Option(None, "--base", "-b", help="Base commit/ref to diff from (e.g. HEAD~3, main, a1b2c3d)"),
|
|
18
|
+
target: str = typer.Option(None, "--target", "-t", help="Target commit/ref to diff to (e.g. HEAD, feature-branch). Requires --base."),
|
|
19
|
+
language: str = typer.Option("auto", "--language", "-l", help="Language for Trailmark parsing (auto, python, typescript, rust, etc.)"),
|
|
20
|
+
llm: bool = typer.Option(False, "--llm", help="Run LLM vulnerability scanning on modified nodes"),
|
|
21
|
+
model: str = typer.Option("gemini/gemini-1.5-pro", "--model", help="LiteLLM model string to use for scanning"),
|
|
22
|
+
concurrency: int = typer.Option(5, "--concurrency", "-c", help="Number of LLM scan requests to run in parallel"),
|
|
23
|
+
cache: bool = typer.Option(True, "--cache/--no-cache", help="Cache LLM findings by content hash in <output>/.llm_cache.json to skip re-scanning unchanged nodes across runs"),
|
|
24
|
+
max_tokens: int = typer.Option(4096, "--max-tokens", help="Max output tokens per LLM scan request. Reasoning models count internal thinking against this budget too — too low can cause empty responses"),
|
|
25
|
+
tokens: bool = typer.Option(False, "--tokens", help="Show total LLM tokens used by the scan (prompt/completion/total, across real API calls -- cache hits don't count)"),
|
|
26
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Print detailed diagnostic output (git commands, worktree setup, node matching, per-node LLM scan progress)")
|
|
27
|
+
):
|
|
28
|
+
def log(msg: str) -> None:
|
|
29
|
+
if verbose:
|
|
30
|
+
console.print(f"[dim] · {msg}[/dim]")
|
|
31
|
+
|
|
32
|
+
if base and target:
|
|
33
|
+
console.print(f"[bold green]Analyzing {repo_path} at depth {depth} — diff {base}..{target}[/bold green]")
|
|
34
|
+
elif base:
|
|
35
|
+
console.print(f"[bold green]Analyzing {repo_path} at depth {depth} — diff {base}..working tree[/bold green]")
|
|
36
|
+
else:
|
|
37
|
+
console.print(f"[bold green]Analyzing {repo_path} at depth {depth} — uncommitted changes[/bold green]")
|
|
38
|
+
# When diffing two commits, all downstream steps (graph analysis AND the
|
|
39
|
+
# LLM scan, which re-reads source files from disk) need to see `target`'s
|
|
40
|
+
# tree — not whatever happens to be checked out in repo_path already.
|
|
41
|
+
# The worktree must stay alive until every step that reads files is done.
|
|
42
|
+
abs_repo = os.path.abspath(repo_path)
|
|
43
|
+
worktree_path = None
|
|
44
|
+
analysis_root = abs_repo
|
|
45
|
+
try:
|
|
46
|
+
if base and target:
|
|
47
|
+
log(f"Checking out '{target}' into a temporary worktree (base+target diff mode)...")
|
|
48
|
+
worktree_path = create_worktree(abs_repo, target)
|
|
49
|
+
analysis_root = worktree_path
|
|
50
|
+
log(f"Worktree ready at {worktree_path}")
|
|
51
|
+
|
|
52
|
+
graph_data = analyze_impact(analysis_root, depth, base, target, language, log=log)
|
|
53
|
+
|
|
54
|
+
num_modified = sum(1 for n in graph_data['nodes'] if n['status'] != 'unchanged')
|
|
55
|
+
console.print(f"[bold blue]Found {num_modified} modified/added nodes.[/bold blue]")
|
|
56
|
+
console.print(f"[bold blue]Total nodes in subgraph: {len(graph_data['nodes'])}[/bold blue]")
|
|
57
|
+
console.print(f"[bold blue]Total edges in subgraph: {len(graph_data['edges'])}[/bold blue]")
|
|
58
|
+
|
|
59
|
+
vulnerabilities = None
|
|
60
|
+
if llm:
|
|
61
|
+
console.print(f"[bold yellow]Running LLM scanner using {model} (concurrency={concurrency})...[/bold yellow]")
|
|
62
|
+
cache_path = os.path.join(output_dir, ".llm_cache.json") if cache else None
|
|
63
|
+
vulnerabilities, token_usage = scan_graph_for_vulnerabilities(
|
|
64
|
+
graph_data, model, log=log, concurrency=concurrency, cache_path=cache_path,
|
|
65
|
+
max_tokens=max_tokens,
|
|
66
|
+
)
|
|
67
|
+
console.print(f"[bold yellow]Found vulnerabilities in {len(vulnerabilities)} nodes.[/bold yellow]")
|
|
68
|
+
|
|
69
|
+
if tokens:
|
|
70
|
+
if token_usage['requests'] == 0:
|
|
71
|
+
console.print("[dim]Token usage: no LLM requests were made (all results came from cache or were skipped).[/dim]")
|
|
72
|
+
elif token_usage['requests'] == token_usage['requests_without_usage']:
|
|
73
|
+
console.print(
|
|
74
|
+
f"[dim]Token usage: unavailable for all {token_usage['requests']} request(s) "
|
|
75
|
+
f"(provider/backend did not report it).[/dim]"
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
counted = token_usage['requests'] - token_usage['requests_without_usage']
|
|
79
|
+
console.print(
|
|
80
|
+
f"[bold magenta]Tokens used:[/bold magenta] "
|
|
81
|
+
f"{token_usage['prompt_tokens']:,} prompt + {token_usage['completion_tokens']:,} completion "
|
|
82
|
+
f"= {token_usage['total_tokens']:,} total across {counted} request(s)"
|
|
83
|
+
)
|
|
84
|
+
if token_usage['requests_without_usage']:
|
|
85
|
+
console.print(
|
|
86
|
+
f"[dim] ({token_usage['requests_without_usage']} additional request(s) had no usage "
|
|
87
|
+
f"data reported by the provider — not counted above)[/dim]"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
j_path, h_path = generate_reports(graph_data, output_dir, vulnerabilities)
|
|
91
|
+
|
|
92
|
+
console.print(f"[bold green]Success![/bold green] Reports generated:")
|
|
93
|
+
console.print(f" - {j_path}")
|
|
94
|
+
console.print(f" - {h_path}")
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
finally:
|
|
100
|
+
if worktree_path:
|
|
101
|
+
log(f"Removing temporary worktree {worktree_path}")
|
|
102
|
+
remove_worktree(abs_repo, worktree_path)
|
|
103
|
+
|
|
104
|
+
def main():
|
|
105
|
+
app()
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import re
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_worktree(repo_path: str, ref: str) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Checks out `ref` into a new temporary git worktree and returns its path.
|
|
12
|
+
|
|
13
|
+
Used so that node locations/contents indexed by Trailmark line up with the
|
|
14
|
+
line numbers reported by `git diff base target` — those line numbers refer
|
|
15
|
+
to `target`'s tree, which may differ arbitrarily from whatever happens to
|
|
16
|
+
be checked out in the caller's working directory.
|
|
17
|
+
"""
|
|
18
|
+
worktree_path = tempfile.mkdtemp(prefix="zairo-worktree-")
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["git", "worktree", "add", "--detach", "--force", worktree_path, ref],
|
|
21
|
+
cwd=repo_path,
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
)
|
|
25
|
+
if result.returncode != 0:
|
|
26
|
+
raise RuntimeError(f"Failed to check out '{ref}' into a worktree: {result.stderr.strip()}")
|
|
27
|
+
return worktree_path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def remove_worktree(repo_path: str, worktree_path: str) -> None:
|
|
31
|
+
subprocess.run(
|
|
32
|
+
["git", "worktree", "remove", "--force", worktree_path],
|
|
33
|
+
cwd=repo_path,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def get_modified_lines(
|
|
39
|
+
repo_path: str,
|
|
40
|
+
base: str = None,
|
|
41
|
+
target: str = None,
|
|
42
|
+
log: Optional[callable] = None,
|
|
43
|
+
) -> Dict[str, Dict[int, str]]:
|
|
44
|
+
"""
|
|
45
|
+
Parses `git diff -U0` to find which lines have been added/modified.
|
|
46
|
+
|
|
47
|
+
- No base/target: compares working tree vs HEAD (uncommitted changes).
|
|
48
|
+
- base only: compares working tree vs that commit.
|
|
49
|
+
- base + target: compares two commits (e.g. HEAD~3..HEAD).
|
|
50
|
+
|
|
51
|
+
Returns a dict mapping absolute file paths to a dict of
|
|
52
|
+
{target line number: representative changed text}. The text is used to
|
|
53
|
+
cheaply filter out non-substantive changes (comments, blank lines)
|
|
54
|
+
before spending an LLM call on them, and to build a windowed view of
|
|
55
|
+
large functions instead of sending their full body.
|
|
56
|
+
|
|
57
|
+
A hunk with zero added lines (a pure deletion, e.g. `@@ -11 +10,0 @@`)
|
|
58
|
+
has no "+" line to anchor to in the target tree, but the enclosing node
|
|
59
|
+
still changed — a deleted validation check or sanitization call is
|
|
60
|
+
exactly the kind of change a security scan most needs to catch. Those
|
|
61
|
+
are recorded under a synthetic marker at the deletion's boundary line
|
|
62
|
+
in the target file, with the removed text as its value, so the
|
|
63
|
+
enclosing node is still found instead of silently skipped.
|
|
64
|
+
"""
|
|
65
|
+
log = log or (lambda msg: None)
|
|
66
|
+
|
|
67
|
+
# Build the git diff command
|
|
68
|
+
cmd = ["git", "diff", "-U0"]
|
|
69
|
+
if base and target:
|
|
70
|
+
cmd += [base, target]
|
|
71
|
+
elif base:
|
|
72
|
+
cmd += [base]
|
|
73
|
+
log(f"Running: {' '.join(cmd)} (cwd={repo_path})")
|
|
74
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
75
|
+
|
|
76
|
+
if result.returncode != 0:
|
|
77
|
+
log(f"git diff failed (exit {result.returncode}): {result.stderr.strip()}")
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
diff_output = result.stdout
|
|
81
|
+
|
|
82
|
+
modified_lines = defaultdict(dict)
|
|
83
|
+
current_file = None
|
|
84
|
+
next_line_num = None
|
|
85
|
+
pending_deletion_line = None
|
|
86
|
+
pending_deletion_text = []
|
|
87
|
+
|
|
88
|
+
def flush_pending_deletion():
|
|
89
|
+
if current_file and pending_deletion_line is not None and pending_deletion_text:
|
|
90
|
+
modified_lines[current_file][pending_deletion_line] = "\n".join(pending_deletion_text)
|
|
91
|
+
|
|
92
|
+
for line in diff_output.splitlines():
|
|
93
|
+
if line.startswith("+++ "):
|
|
94
|
+
flush_pending_deletion()
|
|
95
|
+
pending_deletion_line, pending_deletion_text = None, []
|
|
96
|
+
if line.startswith("+++ b/"):
|
|
97
|
+
# New file path — resolve to absolute so it matches Trailmark's locations
|
|
98
|
+
rel_path = line[6:]
|
|
99
|
+
current_file = os.path.abspath(os.path.join(repo_path, rel_path))
|
|
100
|
+
else:
|
|
101
|
+
# "+++ /dev/null": the whole file was deleted in the target.
|
|
102
|
+
# There's no target-side file to attribute this hunk to, and
|
|
103
|
+
# without resetting this, a stale current_file from the
|
|
104
|
+
# PREVIOUS file section in the diff would silently absorb
|
|
105
|
+
# this file's content -- a genuine cross-file data leak.
|
|
106
|
+
current_file = None
|
|
107
|
+
next_line_num = None
|
|
108
|
+
elif line.startswith("@@ ") and current_file:
|
|
109
|
+
flush_pending_deletion()
|
|
110
|
+
pending_deletion_line, pending_deletion_text = None, []
|
|
111
|
+
# Parse the + part of the hunk header
|
|
112
|
+
match = re.search(r'\+([0-9]+)(?:,([0-9]+))?', line)
|
|
113
|
+
if match:
|
|
114
|
+
start_line = int(match.group(1))
|
|
115
|
+
count = match.group(2)
|
|
116
|
+
count = int(count) if count is not None else 1
|
|
117
|
+
if count > 0:
|
|
118
|
+
next_line_num = start_line
|
|
119
|
+
else:
|
|
120
|
+
next_line_num = None
|
|
121
|
+
pending_deletion_line = max(1, start_line)
|
|
122
|
+
elif current_file and next_line_num is not None and line.startswith("+") and not line.startswith("+++"):
|
|
123
|
+
# With -U0 there are no context lines, so every "+" line after a
|
|
124
|
+
# hunk header maps to the next line number in the added range.
|
|
125
|
+
modified_lines[current_file][next_line_num] = line[1:]
|
|
126
|
+
next_line_num += 1
|
|
127
|
+
elif current_file and pending_deletion_line is not None and line.startswith("-") and not line.startswith("---"):
|
|
128
|
+
pending_deletion_text.append(line[1:])
|
|
129
|
+
|
|
130
|
+
flush_pending_deletion()
|
|
131
|
+
return dict(modified_lines)
|