Skip to content

Commit 7c217e8

Browse files
author
GitHub Copilot
committed
fix: scan full repo in PR workflow (individual file scan lacks SARIF output)
The _analyze_file() code path doesn't support --output-sarif, so scanning individual changed files caused FileNotFoundError on the triage step. Changed PR workflow to 'a3 scan .' (full repo) — the workflow still only triggers when .py files change. Updated README to match. Bumped to v0.1.16.
1 parent a3705ff commit 7c217e8

218 files changed

Lines changed: 422 additions & 171262 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Upload the SARIF file to GitHub's [Code Scanning](https://docs.github.com/en/cod
8787

8888
A3 ships with GitHub Actions workflows that **continuously scan every push and every PR** using a **two-phase approach**:
8989

90-
1. **Non-LLM static analysis** scans only the changed `.py` files and automatically proves 99% as false positives
90+
1. **Non-LLM static analysis** scans the repo and automatically proves 99% as false positives
9191
2. **Agentic LLM triage** investigates the remaining 1% — the LLM reads source files, searches for guard patterns, checks callers and tests, then classifies each finding — zero API keys needed
9292

9393
Every GitHub Actions runner already has a `GITHUB_TOKEN`, which gives access to GitHub Models. That's all the agentic triage needs.
@@ -109,7 +109,7 @@ That's it. Every push to `main`/`master` and every PR that touches Python files
109109

110110
| File | What it does |
111111
|------|-------------|
112-
| `.github/workflows/a3-pr-scan.yml` | **On every push & PR:** scans only the changed `.py` files → agentic LLM investigates each finding (reads files, searches patterns, checks callers) → blocks if new bugs found → uploads SARIF |
112+
| `.github/workflows/a3-pr-scan.yml` | **On every push & PR:** scans the repo → agentic LLM investigates each finding (reads files, searches patterns, checks callers) → blocks if new bugs found → uploads SARIF |
113113
| `.github/workflows/a3-scheduled-scan.yml` | **Weekly (Monday 6 AM UTC):** full-repo scan → agentic triage → auto-files GitHub Issues for new TPs → updates baseline |
114114
| `.a3.yml` | Analysis configuration (what to scan, confidence thresholds, etc.) |
115115
| `.a3-baseline.json` | Known-findings baseline for the ratchet (starts empty) |
@@ -119,7 +119,7 @@ That's it. Every push to `main`/`master` and every PR that touches Python files
119119
```
120120
push to main/master — or — PR opened (touches .py files)
121121
122-
├─ 1. Non-LLM static analysis scans changed files
122+
├─ 1. Non-LLM static analysis scans the repo
123123
│ • Bytecode analysis + Z3 symbolic execution
124124
│ • Automatically proves 99% as false positives
125125
│ • Outputs SARIF with remaining 1% of findings
@@ -155,7 +155,7 @@ When you run `a3 init . --copilot`, it creates `.github/workflows/a3-pr-scan.yml
155155
156156
- name: Run a3
157157
run: |
158-
a3 scan $(cat changed_files.txt | tr '\n' ' ') \
158+
a3 scan . \
159159
--output-sarif a3-results.sarif
160160
161161
- name: Agentic triage # ← the magic step

a3_python/ci/templates/a3-pr-scan.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# ──────────────────────────────────────────────────────────────────────────────
22
# a3 — Continuous scan on every code change
33
#
4-
# Triggers on every push / PR that touches Python files. Scans ONLY the
5-
# changed files, runs agentic LLM triage (the LLM uses tools to explore
6-
# callers, tests, guards, and imports before deciding TP/FP), diffs
7-
# against the committed baseline, and uploads SARIF to Code Scanning.
4+
# Triggers on every push / PR that touches Python files. Scans the repo,
5+
# runs agentic LLM triage (the LLM uses tools to explore callers, tests,
6+
# guards, and imports before deciding TP/FP), diffs against the committed
7+
# baseline, and uploads SARIF to Code Scanning.
88
#
99
# Install: a3 init . --copilot
1010
# Docs: https://github.com/thehalleyyoung/A³
@@ -60,11 +60,11 @@ jobs:
6060
echo "Changed Python files:"
6161
cat changed_files.txt
6262
63-
# ── Run analysis (only changed files) ────────────────────────────
63+
# ── Run analysis ────────────────────────────────────────────────
6464
- name: Run a3
6565
if: steps.changed.outputs.count != '0'
6666
run: |
67-
a3 scan $(cat changed_files.txt | tr '\n' ' ') \
67+
a3 scan . \
6868
--output-sarif a3-results.sarif
6969
continue-on-error: true
7070

examples.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""
2+
Real-world bug examples that a3-python can automatically detect.
3+
4+
Run: a3 scan examples.py --interprocedural
5+
"""
6+
7+
# Example 1: User Authentication
8+
def authenticate_user(username, user_database):
9+
"""
10+
Look up user credentials from database.
11+
BUG: No validation that user exists.
12+
"""
13+
user_record = user_database.get(username)
14+
# BUG: user_record could be None if username not found
15+
return user_record['password_hash'] # NULL_PTR
16+
17+
18+
# Example 2: Percentage Calculation
19+
def calculate_completion_rate(completed, total):
20+
"""
21+
Calculate completion percentage.
22+
BUG: No check for zero total.
23+
"""
24+
if completed > total:
25+
raise ValueError("Invalid: completed > total")
26+
# BUG: total could be 0
27+
return (completed / total) * 100 # DIV_ZERO
28+
29+
30+
# Example 3: Nested Configuration Access
31+
def get_database_host(config):
32+
"""
33+
Extract database host from config.
34+
BUG: No None checks on nested attributes.
35+
"""
36+
# BUG: config or config.database could be None
37+
return config.database.host # NULL_PTR
38+
39+
40+
# Example 4: API Response Processing
41+
def get_first_user_email(api_response):
42+
"""
43+
Get email of first user from API response.
44+
BUG: Assumes response has users and they have emails.
45+
"""
46+
users = api_response['users']
47+
first_user = users[0] # BOUNDS: users could be empty
48+
return first_user['email'] # NULL_PTR: first_user could be None
49+
50+
51+
# Example 5: Report Generation
52+
def get_latest_transaction(transactions):
53+
"""
54+
Get the most recent transaction.
55+
BUG: No check if transactions list is empty.
56+
"""
57+
sorted_txns = sorted(transactions, key=lambda t: t.date, reverse=True)
58+
# BUG: sorted_txns could be empty
59+
latest = sorted_txns[0] # BOUNDS
60+
return latest.amount
61+
62+
63+
# Example 6: Score Averaging
64+
def calculate_average_score(scores):
65+
"""
66+
Calculate average across all scores.
67+
BUG: Doesn't handle empty input.
68+
"""
69+
total = sum(scores.values())
70+
count = len(scores)
71+
# BUG: count could be 0
72+
return total / count # DIV_ZERO
73+
74+
75+
# Example 7: CSV Parsing
76+
def extract_email_from_csv(csv_line):
77+
"""
78+
Parse email from third column of CSV.
79+
BUG: Assumes CSV has at least 3 columns.
80+
"""
81+
fields = csv_line.split(',')
82+
# BUG: fields might have < 3 elements
83+
return fields[2].strip() # BOUNDS
84+
85+
86+
# Example 8: Investment Return
87+
def calculate_roi(profit, cost):
88+
"""
89+
Calculate return on investment percentage.
90+
BUG: No validation of cost.
91+
"""
92+
# BUG: cost could be 0
93+
return (profit / cost) * 100 # DIV_ZERO
94+
95+
96+
# Example 9: Product Pricing
97+
def get_product_total_price(inventory, product_id):
98+
"""
99+
Calculate total price including tax.
100+
BUG: Doesn't validate product exists.
101+
"""
102+
product = inventory.lookup(product_id)
103+
# BUG: product could be None
104+
price = product.base_price # NULL_PTR
105+
tax = product.tax_rate
106+
return price * (1 + tax)
107+
108+
109+
# Example 10: Cache Retrieval
110+
def get_from_cache(cache, key):
111+
"""
112+
Retrieve value from cache.
113+
BUG: Doesn't validate cache entry.
114+
"""
115+
entry = cache.get(key)
116+
# BUG: entry could be None
117+
return entry.data # NULL_PTR

examples_safe.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""
2+
Safe versions with proper validation.
3+
4+
Run: a3 scan examples_safe.py --interprocedural
5+
"""
6+
7+
# Example 1: User Authentication (SAFE)
8+
def authenticate_user(username, user_database):
9+
"""
10+
Look up user credentials with validation.
11+
SAFE: Checks if user exists.
12+
"""
13+
user_record = user_database.get(username)
14+
if user_record is not None:
15+
return user_record['password_hash']
16+
return None
17+
18+
19+
# Example 2: Percentage Calculation (SAFE)
20+
def calculate_completion_rate(completed, total):
21+
"""
22+
Calculate completion percentage safely.
23+
SAFE: Checks for zero before division.
24+
"""
25+
if completed > total:
26+
raise ValueError("Invalid: completed > total")
27+
if total != 0:
28+
return (completed / total) * 100
29+
return 0.0
30+
31+
32+
# Example 3: Nested Configuration Access (SAFE)
33+
def get_database_host(config):
34+
"""
35+
Extract database host with validation.
36+
SAFE: Checks nested attributes.
37+
"""
38+
if config is not None and config.database is not None:
39+
return config.database.host
40+
return "localhost"
41+
42+
43+
# Example 4: API Response Processing (SAFE)
44+
def get_first_user_email(api_response):
45+
"""
46+
Get first user email with validation.
47+
SAFE: Checks array bounds and None values.
48+
"""
49+
users = api_response.get('users', [])
50+
if len(users) > 0:
51+
first_user = users[0]
52+
if first_user is not None:
53+
return first_user.get('email')
54+
return None
55+
56+
57+
# Example 5: Report Generation (SAFE)
58+
def get_latest_transaction(transactions):
59+
"""
60+
Get most recent transaction safely.
61+
SAFE: Checks if list is empty.
62+
"""
63+
sorted_txns = sorted(transactions, key=lambda t: t.date, reverse=True)
64+
if len(sorted_txns) > 0:
65+
return sorted_txns[0].amount
66+
return 0.0
67+
68+
69+
# Example 6: Score Averaging (SAFE)
70+
def calculate_average_score(scores):
71+
"""
72+
Calculate average with empty check.
73+
SAFE: Validates count before division.
74+
"""
75+
total = sum(scores.values())
76+
count = len(scores)
77+
if count > 0:
78+
return total / count
79+
return 0.0
80+
81+
82+
# Example 7: CSV Parsing (SAFE)
83+
def extract_email_from_csv(csv_line):
84+
"""
85+
Parse email with column validation.
86+
SAFE: Checks field count.
87+
"""
88+
fields = csv_line.split(',')
89+
if len(fields) >= 3:
90+
return fields[2].strip()
91+
return None
92+
93+
94+
# Example 8: Investment Return (SAFE)
95+
def calculate_roi(profit, cost):
96+
"""
97+
Calculate ROI with validation.
98+
SAFE: Checks for zero cost.
99+
"""
100+
if cost != 0:
101+
return (profit / cost) * 100
102+
return 0.0
103+
104+
105+
# Example 9: Product Pricing (SAFE)
106+
def get_product_total_price(inventory, product_id):
107+
"""
108+
Calculate price with validation.
109+
SAFE: Checks product exists.
110+
"""
111+
product = inventory.lookup(product_id)
112+
if product is not None:
113+
price = product.base_price
114+
tax = product.tax_rate
115+
return price * (1 + tax)
116+
return 0.0
117+
118+
119+
# Example 10: Cache Retrieval (SAFE)
120+
def get_from_cache(cache, key):
121+
"""
122+
Retrieve from cache safely.
123+
SAFE: Validates entry exists.
124+
"""
125+
entry = cache.get(key)
126+
if entry is not None:
127+
return entry.data
128+
return None

pyfromscratch/.DS_Store

-10 KB
Binary file not shown.

pyfromscratch/__init__.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

pyfromscratch/__main__.py

Lines changed: 0 additions & 11 deletions
This file was deleted.

0 commit comments

Comments
 (0)