@aqaf/cli 0.0.0-stage → 1.1.0
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.
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/bin/aqaf.js +12 -0
- package/index.js +32 -0
- package/package.json +50 -4
- package/vendor/aqaf/__init__.py +2 -0
- package/vendor/aqaf/__main__.py +3 -0
- package/vendor/aqaf/cli.py +583 -0
- package/vendor/aqaf/data/catalog.json +562 -0
- package/vendor/aqaf/data/packs/automation.json +16 -0
- package/vendor/aqaf/data/packs/mobile.json +13 -0
- package/vendor/aqaf/data/packs/systems.json +17 -0
- package/vendor/aqaf/data/packs/web.json +16 -0
- package/vendor/aqaf/data/profiles/ai_pipeline.json +39 -0
- package/vendor/aqaf/data/profiles/android.json +34 -0
- package/vendor/aqaf/data/profiles/api_backend.json +44 -0
- package/vendor/aqaf/data/profiles/browser_extension.json +29 -0
- package/vendor/aqaf/data/profiles/cli_automation.json +34 -0
- package/vendor/aqaf/data/profiles/desktop_utility.json +39 -0
- package/vendor/aqaf/data/profiles/embedded_iot.json +34 -0
- package/vendor/aqaf/data/profiles/google_apps_script.json +34 -0
- package/vendor/aqaf/data/profiles/mcp_agent_server.json +34 -0
- package/vendor/aqaf/data/profiles/multirepo_system.json +29 -0
- package/vendor/aqaf/data/profiles/npm_library.json +39 -0
- package/vendor/aqaf/data/profiles/rust_app.json +34 -0
- package/vendor/aqaf/data/profiles/saas_or_web_app.json +44 -0
- package/vendor/aqaf/data/profiles/scraper_data_pipeline.json +39 -0
- package/vendor/aqaf/data/profiles/swift_app.json +34 -0
- package/vendor/aqaf/data/profiles/tauri_electron.json +34 -0
- package/vendor/aqaf/data/profiles/unknown_or_general_app.json +25 -0
- package/vendor/aqaf/data/profiles/windows_client_server.json +34 -0
- package/vendor/aqaf/data/profiles/wordpress_plugin.json +39 -0
- package/vendor/aqaf/data/source_registry.json +302 -0
- package/vendor/aqaf/registry.py +124 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
AQAF local scanner.
|
|
4
|
+
|
|
5
|
+
This is intentionally lightweight and dependency-free. It does not replace language-specific tools such as ArchUnit,
|
|
6
|
+
dependency-cruiser, SonarQube, Semgrep, or gitleaks. It gives Codex and humans a first architecture-quality report.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import argparse, ast, hashlib, json, os, re, sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .registry import Registry, ProfileError, read_doc
|
|
14
|
+
|
|
15
|
+
def _metadata():
|
|
16
|
+
"""Bundled source index; gracefully return empty if a developer copy is incomplete."""
|
|
17
|
+
base = Path(__file__).resolve().parent / 'data'
|
|
18
|
+
try:
|
|
19
|
+
rules = {v['id']: v for v in json.loads((base / 'catalog.json').read_text(encoding='utf-8'))}
|
|
20
|
+
sources = {v['id']: v for v in json.loads((base / 'source_registry.json').read_text(encoding='utf-8'))}
|
|
21
|
+
return rules, sources
|
|
22
|
+
except (OSError, ValueError, KeyError):
|
|
23
|
+
return {}, {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SKIP_DIRS = {'.git', '.hg', '.svn', 'node_modules', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', '.gradle', 'target', 'vendor'}
|
|
27
|
+
CODE_EXTS = {'.gs','.py','.js','.jsx','.ts','.tsx','.php','.java','.kt','.kts','.cs','.go','.rs','.swift','.m','.mm','.cpp','.c','.h'}
|
|
28
|
+
SECRET_PATTERNS = [
|
|
29
|
+
re.compile(r'(?i)(api[_-]?key|secret|token|password)\s*[=:]\s*["\'][^"\']{8,}["\']'),
|
|
30
|
+
re.compile(r'AKIA[0-9A-Z]{16}'),
|
|
31
|
+
re.compile(r'(?i)-----BEGIN (RSA|OPENSSH|PRIVATE) KEY-----'),
|
|
32
|
+
]
|
|
33
|
+
AI_TERMS = ['ollama','openai','anthropic','langchain','llama','whisper','embedding','rag','prompt','model_name']
|
|
34
|
+
WP_TERMS = ['add_action','add_filter','register_activation_hook','woocommerce','wp_enqueue_script']
|
|
35
|
+
|
|
36
|
+
RULE_META = {
|
|
37
|
+
'AQ-DOC-001': ('medium','Architecture decisions should be documented'),
|
|
38
|
+
'AQ-DEP-001': ('high','Core/domain logic should not depend on infrastructure details'),
|
|
39
|
+
'AQ-MOD-001': ('high','Feature or domain modules should have clear ownership'),
|
|
40
|
+
'AQ-TST-001': ('high','Architecture-sensitive areas need tests before refactoring'),
|
|
41
|
+
'AQ-CFG-001': ('high','Configuration and secrets should be externalized'),
|
|
42
|
+
'AQ-OBS-001': ('medium','Operational code should include logging and explicit error handling'),
|
|
43
|
+
'AQ-DATA-001': ('high','Data ownership and migrations should be explicit'),
|
|
44
|
+
'AQ-WP-001': ('high','WordPress hooks should be thin and delegate to services'),
|
|
45
|
+
'AQ-AND-001': ('high','Android UI state and business logic should be separated'),
|
|
46
|
+
'AQ-AI-001': ('high','AI providers, prompts, and models should be configurable and reproducible'),
|
|
47
|
+
'AQ-CI-001': ('medium','New code should pass automated quality gates'),
|
|
48
|
+
'AQ-SWIFT-001': ('high','Swift UI should not own business workflows'),
|
|
49
|
+
'AQ-SWIFT-002': ('medium','Swift features/packages should express boundaries when growing'),
|
|
50
|
+
'AQ-RUST-001': ('high','Rust binary entrypoints should be thin'),
|
|
51
|
+
'AQ-RUST-002': ('high','Unsafe Rust should be isolated, documented, and tested'),
|
|
52
|
+
'AQ-RUST-003': ('medium','Rust dependency risk should be audited'),
|
|
53
|
+
'AQ-EXT-001': ('high','Browser extension permissions should be proportionate'),
|
|
54
|
+
'AQ-GAS-001': ('medium','Google Apps Script triggers should delegate external IO'),
|
|
55
|
+
'AQ-NPM-001': ('medium','Node library should declare a stable public entrypoint'),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class Finding:
|
|
59
|
+
def __init__(self, rule_id, severity, title, evidence, risk, recommendation, confidence='medium'):
|
|
60
|
+
self.rule_id=rule_id; self.severity=severity; self.title=title
|
|
61
|
+
self.evidence=evidence; self.risk=risk; self.recommendation=recommendation; self.confidence=confidence
|
|
62
|
+
def to_dict(self): return self.__dict__
|
|
63
|
+
|
|
64
|
+
def iter_files(root: Path):
|
|
65
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
66
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith('.cache'))
|
|
67
|
+
for f in sorted(filenames):
|
|
68
|
+
p=Path(dirpath)/f
|
|
69
|
+
if p.is_symlink(): continue
|
|
70
|
+
try:
|
|
71
|
+
if p.stat().st_size > 1_500_000: continue
|
|
72
|
+
except OSError: continue
|
|
73
|
+
yield p
|
|
74
|
+
|
|
75
|
+
def rel(p, root):
|
|
76
|
+
try: return str(p.relative_to(root))
|
|
77
|
+
except Exception: return str(p)
|
|
78
|
+
|
|
79
|
+
def read_text(p):
|
|
80
|
+
try: return p.read_text(encoding='utf-8', errors='ignore')
|
|
81
|
+
except Exception: return ''
|
|
82
|
+
|
|
83
|
+
def scan(root: Path, profile: str = "auto", registry=None, pack: str = "auto", capabilities=()):
|
|
84
|
+
files=list(iter_files(root))
|
|
85
|
+
code=[p for p in files if p.suffix.lower() in CODE_EXTS]
|
|
86
|
+
# Exclude AQAF's own scanner when it has been copied into a target repo; otherwise it can bias product detection.
|
|
87
|
+
app_code=[p for p in code if p.name.lower() != 'aqaf.py']
|
|
88
|
+
detection_code = app_code if app_code else code
|
|
89
|
+
exts=Counter(p.suffix.lower() for p in files)
|
|
90
|
+
names={p.name.lower() for p in files}
|
|
91
|
+
path_text='\n'.join(rel(p,root).lower() for p in files)
|
|
92
|
+
sample_text='\n'.join(read_text(p)[:2000].lower() for p in detection_code[:250])
|
|
93
|
+
|
|
94
|
+
findings=[]
|
|
95
|
+
signals=[]
|
|
96
|
+
|
|
97
|
+
# Data-only registry makes product types additive without changing the scanner core.
|
|
98
|
+
reg = registry or Registry()
|
|
99
|
+
detected_product, evidence_signals, detection_confidence, candidates = reg.detect(files,root,detection_code,pack)
|
|
100
|
+
product = detected_product
|
|
101
|
+
if profile != 'auto':
|
|
102
|
+
chosen=reg.get_profile(profile)
|
|
103
|
+
if pack!='auto' and profile not in reg.get_pack(pack)['profiles']:
|
|
104
|
+
raise ProfileError(f'Profile {profile} is not a member of pack {pack}')
|
|
105
|
+
product=profile
|
|
106
|
+
detection_confidence='user-selected'
|
|
107
|
+
chosen=reg.get_profile(product)
|
|
108
|
+
target=chosen['target_architecture']
|
|
109
|
+
signals=evidence_signals
|
|
110
|
+
technologies=sorted({x for x in ['python' if '.py' in exts else None,
|
|
111
|
+
'javascript' if {'.js','.jsx','.ts','.tsx'} & exts.keys() else None,
|
|
112
|
+
'swift' if '.swift' in exts else None,'rust' if '.rs' in exts else None,
|
|
113
|
+
'kotlin' if {'.kt','.kts'} & exts.keys() else None,
|
|
114
|
+
'php' if '.php' in exts else None,'csharp' if '.cs' in exts else None]
|
|
115
|
+
if x})
|
|
116
|
+
for c in capabilities:
|
|
117
|
+
if not re.fullmatch(r'[a-z][a-z0-9_]{1,63}',c):
|
|
118
|
+
raise ValueError(f'Invalid capability: {c}')
|
|
119
|
+
|
|
120
|
+
# docs/ADRs
|
|
121
|
+
has_arch_docs = any('adr' in rel(p,root).lower() or 'architecture' in rel(p,root).lower() for p in files if p.suffix.lower() in {'.md','.adoc','.rst'})
|
|
122
|
+
if not has_arch_docs:
|
|
123
|
+
findings.append(Finding('AQ-DOC-001','medium','No architecture decision records or architecture docs found',
|
|
124
|
+
'No files with ADR/architecture naming were found.',
|
|
125
|
+
'Important architecture choices may be forgotten or repeatedly debated.',
|
|
126
|
+
'Add docs/architecture and start recording important decisions with ADRs.', 'high'))
|
|
127
|
+
|
|
128
|
+
# tests
|
|
129
|
+
test_files=[p for p in files if re.search(r'(^|/|\\)(tests?|__tests__)(/|\\)', str(p).lower()) or re.search(r'(test_|_test\.|\.spec\.|\.test\.)', p.name.lower())]
|
|
130
|
+
if len(test_files)==0:
|
|
131
|
+
findings.append(Finding('AQ-TST-001','high','No tests detected',
|
|
132
|
+
'No conventional test files or test directories were found.',
|
|
133
|
+
'Architecture refactoring becomes risky because behavior is not protected.',
|
|
134
|
+
'Add characterization tests around critical workflows before structural refactoring.', 'high'))
|
|
135
|
+
elif len(test_files) < max(3, len(code)//50):
|
|
136
|
+
findings.append(Finding('AQ-TST-001','medium','Low visible test density',
|
|
137
|
+
f'{len(test_files)} test-like files for {len(code)} code files.',
|
|
138
|
+
'Important behavior may be unprotected during AI-generated changes.',
|
|
139
|
+
'Add tests for the modules affected by upcoming architecture work.', 'medium'))
|
|
140
|
+
|
|
141
|
+
# CI
|
|
142
|
+
has_ci=any('.github/workflows' in rel(p,root).lower() or p.name.lower() in {'gitlab-ci.yml','.gitlab-ci.yml','circle.yml'} for p in files)
|
|
143
|
+
if not has_ci:
|
|
144
|
+
findings.append(Finding('AQ-CI-001','medium','No CI workflow detected',
|
|
145
|
+
'No common CI configuration was found.',
|
|
146
|
+
'New architecture regressions may be introduced without automated feedback.',
|
|
147
|
+
'Add CI for tests, linting, and AQAF scan.', 'high'))
|
|
148
|
+
|
|
149
|
+
# secrets/config
|
|
150
|
+
secret_hits=[]
|
|
151
|
+
for p in code[:1000]:
|
|
152
|
+
txt=read_text(p)
|
|
153
|
+
for pat in SECRET_PATTERNS:
|
|
154
|
+
if pat.search(txt):
|
|
155
|
+
secret_hits.append(rel(p,root)); break
|
|
156
|
+
if secret_hits:
|
|
157
|
+
findings.append(Finding('AQ-CFG-001','high','Possible hard-coded secret or sensitive config detected',
|
|
158
|
+
'Potential secret-like pattern in: '+', '.join(secret_hits[:10]),
|
|
159
|
+
'Secrets in code can leak credentials and make deployment unsafe.',
|
|
160
|
+
'Move secrets/config to environment variables or a secret manager; rotate any exposed credentials.', 'medium'))
|
|
161
|
+
|
|
162
|
+
# module boundaries
|
|
163
|
+
module_dirs = [d for d in ['modules','features','domains','domain','application','infrastructure','adapters','services','src'] if (root/d).exists()]
|
|
164
|
+
if len(code)>30 and not any((root/d).exists() for d in ['modules','features','domain','domains','application']):
|
|
165
|
+
findings.append(Finding('AQ-MOD-001','high','Growing codebase lacks obvious domain/feature module boundaries',
|
|
166
|
+
f'{len(code)} code files found, but no conventional modules/features/domain/application boundary directories.',
|
|
167
|
+
'Feature additions may spread logic across unrelated files, increasing change blast radius.',
|
|
168
|
+
'Introduce feature/domain modules and document module contracts before adding many more features.', 'medium'))
|
|
169
|
+
|
|
170
|
+
# dependency direction heuristic
|
|
171
|
+
dep_hits=[]
|
|
172
|
+
for p in code:
|
|
173
|
+
rp=rel(p,root).lower()
|
|
174
|
+
if any(seg in ('/' + rp.replace('\\','/')) for seg in ['/domain/','/core/']):
|
|
175
|
+
txt=read_text(p).lower()
|
|
176
|
+
volatile=['sqlalchemy','django','flask','requests','boto3','firebase','androidx','wordpress','wp_','openai','ollama','stripe']
|
|
177
|
+
if any(v in txt for v in volatile):
|
|
178
|
+
dep_hits.append(rel(p,root))
|
|
179
|
+
if dep_hits:
|
|
180
|
+
findings.append(Finding('AQ-DEP-001','high','Core/domain code appears coupled to infrastructure or framework details',
|
|
181
|
+
'Potential volatile imports/references in: '+', '.join(dep_hits[:10]),
|
|
182
|
+
'Core logic becomes harder to test, replace, or reuse.',
|
|
183
|
+
'Move concrete provider/framework/database calls to infrastructure adapters and expose ports/interfaces to core.', 'medium'))
|
|
184
|
+
|
|
185
|
+
# logging/error handling heuristic
|
|
186
|
+
operational_terms=['requests.','fetch(','axios','subprocess','open(','write_text','read_text','sqlite','psycopg','mysql','smtplib','shutil','ffmpeg','yt-dlp']
|
|
187
|
+
operational_files=[]; logging_files=0
|
|
188
|
+
for p in code:
|
|
189
|
+
txt=read_text(p).lower()
|
|
190
|
+
if any(t in txt for t in operational_terms): operational_files.append(p)
|
|
191
|
+
if any(t in txt for t in ['logging.','logger.','console.error','log.error','print(']): logging_files+=1
|
|
192
|
+
if operational_files and logging_files < max(1, len(operational_files)//5):
|
|
193
|
+
findings.append(Finding('AQ-OBS-001','medium','Operational code has little visible logging',
|
|
194
|
+
f'{len(operational_files)} files appear to touch IO/network/processes; logging appears in {logging_files} files.',
|
|
195
|
+
'Failures may be hard to diagnose in production or on user machines.',
|
|
196
|
+
'Add structured logs around network, filesystem, subprocess, and provider boundaries.', 'low'))
|
|
197
|
+
|
|
198
|
+
# data migrations
|
|
199
|
+
if product in {'saas_or_web_app','ai_pipeline','api_backend'}:
|
|
200
|
+
has_db=any(x in sample_text for x in ['sqlite','postgres','mysql','sqlalchemy','prisma','typeorm','sequelize','mongoose'])
|
|
201
|
+
has_migrations=any('migration' in rel(p,root).lower() or 'alembic' in rel(p,root).lower() or 'prisma/migrations' in rel(p,root).lower() for p in files)
|
|
202
|
+
if has_db and not has_migrations:
|
|
203
|
+
findings.append(Finding('AQ-DATA-001','high','Database usage detected without obvious migration strategy',
|
|
204
|
+
'Database-related terms were found, but no migration directory/tooling was detected.',
|
|
205
|
+
'Schema changes may be unsafe or undocumented as the product grows.',
|
|
206
|
+
'Add migration tooling and document data ownership, retention, and backup expectations.', 'medium'))
|
|
207
|
+
|
|
208
|
+
# WP
|
|
209
|
+
if product=='wordpress_plugin':
|
|
210
|
+
root_php=[p for p in code if p.suffix=='.php' and p.parent==root]
|
|
211
|
+
big=[p for p in root_php if p.stat().st_size>25000]
|
|
212
|
+
if big or not any((root/d).exists() for d in ['src','includes','app']):
|
|
213
|
+
findings.append(Finding('AQ-WP-001','high','WordPress plugin may have hook-heavy or root-file-heavy structure',
|
|
214
|
+
'Root PHP files or missing src/includes structure suggest plugin logic may be concentrated around hooks.',
|
|
215
|
+
'Hook-heavy plugins become hard to test and maintain.',
|
|
216
|
+
'Keep hooks thin and delegate behavior to service classes; add nonce/capability/security checks.', 'medium'))
|
|
217
|
+
|
|
218
|
+
# Android
|
|
219
|
+
if product=='android':
|
|
220
|
+
has_vm=any('viewmodel' in rel(p,root).lower() for p in code)
|
|
221
|
+
has_multi=sum(1 for p in files if p.name.lower() in {'build.gradle','build.gradle.kts'})>1
|
|
222
|
+
if not has_vm or not has_multi:
|
|
223
|
+
findings.append(Finding('AQ-AND-001','medium','Android structure may need stronger UI/business separation or modularization',
|
|
224
|
+
f'ViewModel files detected: {has_vm}. Multiple Gradle modules detected: {has_multi}.',
|
|
225
|
+
'Growing Android apps become difficult to test when UI, state, and data logic are mixed.',
|
|
226
|
+
'Use ViewModels/repositories/use cases; consider feature modules for larger apps.', 'medium'))
|
|
227
|
+
|
|
228
|
+
# AI
|
|
229
|
+
if product=='ai_pipeline':
|
|
230
|
+
prompt_files=[p for p in files if 'prompt' in rel(p,root).lower() and p.suffix.lower() in {'.md','.txt','.yaml','.yml','.json'}]
|
|
231
|
+
has_cache=any('cache' in rel(p,root).lower() for p in files)
|
|
232
|
+
if not prompt_files or not has_cache:
|
|
233
|
+
findings.append(Finding('AQ-AI-001','high','AI pipeline lacks obvious prompt registry or cache/resume structure',
|
|
234
|
+
f'Prompt files: {len(prompt_files)}. Cache-related paths found: {has_cache}.',
|
|
235
|
+
'AI output may be hard to reproduce, debug, or resume after failure.',
|
|
236
|
+
'Centralize prompts/model config and store intermediate pipeline artifacts.', 'medium'))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Swift
|
|
240
|
+
if product=='swift_app':
|
|
241
|
+
swift_files=[p for p in code if p.suffix.lower()=='.swift']
|
|
242
|
+
ui_terms=['struct ', ' view {', ': view', 'uiviewcontroller', 'nsviewcontroller']
|
|
243
|
+
io_terms=['urlsession', 'filemanager', 'sqlite', 'coredata', 'userdefaults', 'keychain', 'openai', 'ollama', 'firebase', 'alamofire', 'stripe']
|
|
244
|
+
ui_io=[]
|
|
245
|
+
for p in swift_files:
|
|
246
|
+
txt=read_text(p).lower()
|
|
247
|
+
rp=rel(p,root).lower()
|
|
248
|
+
if any(u in txt for u in ui_terms) and any(i in txt for i in io_terms):
|
|
249
|
+
ui_io.append(rel(p,root))
|
|
250
|
+
if ui_io:
|
|
251
|
+
findings.append(Finding('AQ-SWIFT-001','high','Swift UI layer appears to own IO or business workflows',
|
|
252
|
+
'Possible UI+IO coupling in: '+', '.join(ui_io[:10]),
|
|
253
|
+
'SwiftUI views or UIKit controllers with direct IO/business logic become hard to test and refactor safely.',
|
|
254
|
+
'Move workflows to ViewModel/store/use case/service; inject protocols/adapters and add state/service tests.', 'medium'))
|
|
255
|
+
has_packages=any('package.swift'==p.name.lower() for p in files) or 'packages/' in path_text
|
|
256
|
+
has_features=any(seg in path_text for seg in ['features/','feature/','modules/'])
|
|
257
|
+
if len(swift_files)>25 and not (has_packages or has_features):
|
|
258
|
+
findings.append(Finding('AQ-SWIFT-002','medium','Growing Swift project lacks obvious feature/package boundaries',
|
|
259
|
+
f'{len(swift_files)} Swift files found without obvious Packages/Features/modules structure.',
|
|
260
|
+
'AI-generated changes may spread across unrelated UI and service code.',
|
|
261
|
+
'Introduce feature folders or Swift packages and document module contracts.', 'medium'))
|
|
262
|
+
|
|
263
|
+
# Rust
|
|
264
|
+
if product=='rust_app':
|
|
265
|
+
rust_files=[p for p in code if p.suffix.lower()=='.rs']
|
|
266
|
+
main=root/'src'/'main.rs'
|
|
267
|
+
lib=root/'src'/'lib.rs'
|
|
268
|
+
if main.exists():
|
|
269
|
+
mt=read_text(main)
|
|
270
|
+
main_lines=len([ln for ln in mt.splitlines() if ln.strip() and not ln.strip().startswith('//')])
|
|
271
|
+
if main_lines>180 or (not lib.exists() and len(rust_files)>3):
|
|
272
|
+
findings.append(Finding('AQ-RUST-001','high','Rust binary entrypoint may contain too much core logic',
|
|
273
|
+
f'src/main.rs has about {main_lines} non-comment lines; src/lib.rs exists: {lib.exists()}.',
|
|
274
|
+
'Large main.rs files are harder to unit-test and reuse; AI refactors become riskier.',
|
|
275
|
+
'Move core workflows into src/lib.rs/modules or workspace crates; keep main.rs thin.', 'medium'))
|
|
276
|
+
unsafe_hits=[]
|
|
277
|
+
for p in rust_files:
|
|
278
|
+
txt=read_text(p)
|
|
279
|
+
if 'unsafe ' in txt or 'unsafe{' in txt.replace(' ',''):
|
|
280
|
+
# crude safety comment check near unsafe
|
|
281
|
+
if 'SAFETY:' not in txt and 'Safety:' not in txt:
|
|
282
|
+
unsafe_hits.append(rel(p,root))
|
|
283
|
+
if unsafe_hits:
|
|
284
|
+
findings.append(Finding('AQ-RUST-002','high','Unsafe Rust detected without obvious SAFETY documentation',
|
|
285
|
+
'Unsafe code in: '+', '.join(unsafe_hits[:10]),
|
|
286
|
+
'Unsafe code can bypass Rust safety guarantees; hidden assumptions may become production bugs.',
|
|
287
|
+
'Isolate unsafe code, add SAFETY comments/invariants and tests; consider Miri/fuzzing for critical paths.', 'medium'))
|
|
288
|
+
has_cargo_lock='cargo.lock' in names
|
|
289
|
+
has_deny='deny.toml' in names or 'cargo-deny' in sample_text or 'cargo deny' in path_text
|
|
290
|
+
if has_cargo_lock and not has_deny:
|
|
291
|
+
findings.append(Finding('AQ-RUST-003','medium','Rust dependency audit policy not detected',
|
|
292
|
+
'Cargo.lock found, but no deny.toml/cargo-deny policy was detected.',
|
|
293
|
+
'Advisories, duplicate crates, disallowed licenses, or source issues may be missed.',
|
|
294
|
+
'Add cargo-deny or equivalent dependency/security/license checks to CI.', 'medium'))
|
|
295
|
+
|
|
296
|
+
# New specialized extension detectors. They are opt-in per selected product profile
|
|
297
|
+
# and are transparent heuristics, not comprehensive security assessments.
|
|
298
|
+
if product == 'browser_extension':
|
|
299
|
+
for manifest in (p for p in files if p.name.lower()=='manifest.json'):
|
|
300
|
+
try:
|
|
301
|
+
m=json.loads(read_text(manifest))
|
|
302
|
+
if not isinstance(m,dict) or 'manifest_version' not in m: continue
|
|
303
|
+
perms=m.get('host_permissions',[])+m.get('permissions',[])
|
|
304
|
+
if not isinstance(perms,list): continue
|
|
305
|
+
broad=sorted(str(v) for v in perms if v in ('<all_urls>','*://*/*','http://*/*','https://*/*'))
|
|
306
|
+
if broad:
|
|
307
|
+
findings.append(Finding('AQ-EXT-001','high','Broad extension host permissions need justification',
|
|
308
|
+
f'{rel(manifest,root)} declares: {", ".join(broad)}',
|
|
309
|
+
'Broad permissions increase the potential impact of extension defects or compromise.',
|
|
310
|
+
'Reduce host permissions or use optional permissions and document why scope is required.', 'high'))
|
|
311
|
+
except (ValueError,TypeError): pass
|
|
312
|
+
if product == 'google_apps_script':
|
|
313
|
+
for p in code:
|
|
314
|
+
if p.suffix.lower() not in {'.js','.gs'}: continue
|
|
315
|
+
content=read_text(p)
|
|
316
|
+
# Only nearby text around a trigger signature is inspected; avoids pretending to parse the JS AST.
|
|
317
|
+
for match in re.finditer(r'function\s+(onEdit|onOpen|doGet|doPost)\s*\(',content):
|
|
318
|
+
neighborhood=content[match.start():match.start()+900]
|
|
319
|
+
if 'UrlFetchApp.fetch(' in neighborhood or 'GmailApp.sendEmail(' in neighborhood:
|
|
320
|
+
findings.append(Finding('AQ-GAS-001','medium','Trigger/web entrypoint may own network/email IO',
|
|
321
|
+
f'{rel(p,root)}: {match.group(1)} near external service invocation',
|
|
322
|
+
'When entrypoints contain integration logic, retries, quotas and tests become harder to isolate.',
|
|
323
|
+
'Keep entrypoint thin and delegate to an injected service; verify actual function boundaries.', 'low'))
|
|
324
|
+
break
|
|
325
|
+
if product == 'npm_library':
|
|
326
|
+
for p in files:
|
|
327
|
+
if p.parent==root and p.name=='package.json':
|
|
328
|
+
try:
|
|
329
|
+
data=json.loads(read_text(p))
|
|
330
|
+
if not any(data.get(k) for k in ('exports','main','bin')):
|
|
331
|
+
findings.append(Finding('AQ-NPM-001','medium','No explicit library export or executable entrypoint detected',
|
|
332
|
+
'package.json has none of exports, main, bin.',
|
|
333
|
+
'Consumers may rely on ambiguous package entrypoints.',
|
|
334
|
+
'Define an intentional public API entrypoint or command; test the packed tarball.', 'medium'))
|
|
335
|
+
except ValueError: pass
|
|
336
|
+
|
|
337
|
+
# score
|
|
338
|
+
score=100
|
|
339
|
+
for f in findings:
|
|
340
|
+
score -= {'high':12,'medium':7,'low':3}.get(f.severity,5)
|
|
341
|
+
score=max(0,score)
|
|
342
|
+
lang_summary={k:v for k,v in exts.most_common(15)}
|
|
343
|
+
result={
|
|
344
|
+
'aqaf_version': __version__,
|
|
345
|
+
'assessor': 'static-heuristic',
|
|
346
|
+
'score_kind': 'provisional_nonvalidated_heuristic_index',
|
|
347
|
+
'not_a_certification': True,
|
|
348
|
+
'profile_confidence': detection_confidence,
|
|
349
|
+
'detected_product_type': detected_product,
|
|
350
|
+
'limitations': ['Static structure and regex heuristics only', 'Does not execute target repository code', 'No measured runtime behavior, threat modeling, or performance tests', 'File absence is not proof a capability is absent', 'Index is not independently calibrated or certified'],
|
|
351
|
+
'root':str(root),
|
|
352
|
+
'product_type':product,
|
|
353
|
+
'selected_pack':pack,
|
|
354
|
+
'product_profile_status':chosen['status'],
|
|
355
|
+
'product_profile_coverage':chosen['coverage'],
|
|
356
|
+
'detection_candidates':candidates,
|
|
357
|
+
'technologies':technologies,
|
|
358
|
+
'declared_capabilities':sorted(set(capabilities)),
|
|
359
|
+
'recommended_target_architecture':target,
|
|
360
|
+
'score':score,
|
|
361
|
+
'files_scanned':len(files),
|
|
362
|
+
'code_files':len(code),
|
|
363
|
+
'extensions':lang_summary,
|
|
364
|
+
'signals':signals,
|
|
365
|
+
'findings':[f.to_dict() for f in findings]
|
|
366
|
+
}
|
|
367
|
+
# Methodological references are context, not validation or endorsement of detectors.
|
|
368
|
+
catalog, registry = _metadata()
|
|
369
|
+
source_ids = set()
|
|
370
|
+
for f in result['findings']:
|
|
371
|
+
refs = catalog.get(f['rule_id'], {}).get('source_ids', [])
|
|
372
|
+
f['source_ids'] = refs
|
|
373
|
+
source_ids.update(refs)
|
|
374
|
+
result['source_registry'] = [
|
|
375
|
+
{'id': sid, 'title': registry[sid]['title'], 'url': registry[sid]['url'], 'type': registry[sid]['type']}
|
|
376
|
+
for sid in sorted(source_ids) if sid in registry
|
|
377
|
+
]
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
def md_report(r):
|
|
381
|
+
lines=[]
|
|
382
|
+
lines.append('# AQAF Architecture Report')
|
|
383
|
+
lines.append('')
|
|
384
|
+
lines.append(f"**AQAF version:** {r['aqaf_version']}")
|
|
385
|
+
lines.append(f"**Product type:** {r['product_type']}")
|
|
386
|
+
lines.append(f"**Pack:** {r.get('selected_pack','auto')} | **Detector coverage:** {r.get('product_profile_coverage','unknown')}")
|
|
387
|
+
lines.append(f"**Technologies:** {', '.join(r.get('technologies',[])) or 'not classified'}")
|
|
388
|
+
lines.append(f"**Declared capabilities:** {', '.join(r.get('declared_capabilities',[])) or 'none'}")
|
|
389
|
+
lines.append(f"**Provisional heuristic index:** {r['score']} / 100 (NOT a validated quality score or certification)")
|
|
390
|
+
lines.append(f"**Product classification:** {r['profile_confidence']} confidence; inferred {r['detected_product_type']}")
|
|
391
|
+
lines.append(f"**Recommended target:** {r['recommended_target_architecture']}")
|
|
392
|
+
lines.append(f"**Files scanned:** {r['files_scanned']} total, {r['code_files']} code files")
|
|
393
|
+
lines.append('')
|
|
394
|
+
lines.append('## Extension summary')
|
|
395
|
+
lines.append('')
|
|
396
|
+
for ext,count in r['extensions'].items(): lines.append(f'- `{ext or "[no extension]"}`: {count}')
|
|
397
|
+
lines.append('')
|
|
398
|
+
lines.append('## Findings')
|
|
399
|
+
lines.append('')
|
|
400
|
+
if not r['findings']:
|
|
401
|
+
lines.append('No major AQAF findings detected by the lightweight scanner. This does not mean the architecture is perfect; run language-specific tools and human review.')
|
|
402
|
+
for i,f in enumerate(r['findings'],1):
|
|
403
|
+
lines.append(f"### {i}. {f['rule_id']} — {f['title']}")
|
|
404
|
+
lines.append('')
|
|
405
|
+
lines.append(f"- **Severity:** {f['severity']}")
|
|
406
|
+
lines.append(f"- **Confidence:** {f['confidence']}")
|
|
407
|
+
if f.get('source_ids'): lines.append(f"- **Foundational sources (not detector validation):** {', '.join(f['source_ids'])}")
|
|
408
|
+
lines.append(f"- **Observed evidence:** {f['evidence']}")
|
|
409
|
+
lines.append(f"- **Risk:** {f['risk']}")
|
|
410
|
+
lines.append(f"- **Recommendation:** {f['recommendation']}")
|
|
411
|
+
lines.append('')
|
|
412
|
+
if r.get('suppressed_findings'):
|
|
413
|
+
lines.append(f"## Suppressed findings ({len(r['suppressed_findings'])})")
|
|
414
|
+
lines.append('Suppression does not verify safety; each entry requires a written reason.')
|
|
415
|
+
lines.append('')
|
|
416
|
+
for f in r['suppressed_findings']:
|
|
417
|
+
lines.append(f"- {f['rule_id']} ({f['fingerprint'][:12]}): {f['suppression_reason']}")
|
|
418
|
+
lines.append('')
|
|
419
|
+
if r.get('source_registry'):
|
|
420
|
+
lines.append('## Source references')
|
|
421
|
+
lines.append('These sources contextualize the architectural concern, but do **not** endorse AQAF or prove detector accuracy.')
|
|
422
|
+
for src in r['source_registry']:
|
|
423
|
+
lines.append(f"- {src['id']}: {src['title']} — {src['url']}")
|
|
424
|
+
lines.append('')
|
|
425
|
+
lines.append('## Limitations')
|
|
426
|
+
for limit in r['limitations']:
|
|
427
|
+
lines.append('- ' + limit)
|
|
428
|
+
lines.append('')
|
|
429
|
+
lines.append('## Next Codex prompt')
|
|
430
|
+
lines.append('')
|
|
431
|
+
lines.append('```text')
|
|
432
|
+
lines.append('Read AGENTS.md and ARCHITECTURE_REPORT.md. Do not edit files yet. Turn the top 3 AQAF findings into a safe, tests-first refactor roadmap with small commits and rollback strategy.')
|
|
433
|
+
lines.append('```')
|
|
434
|
+
lines.append('')
|
|
435
|
+
return '\n'.join(lines)
|
|
436
|
+
|
|
437
|
+
def fingerprint(f):
|
|
438
|
+
"""Stable enough for baseline comparison when rule + summarized evidence stay unchanged."""
|
|
439
|
+
payload = f['rule_id'] + '\0' + f['evidence']
|
|
440
|
+
return hashlib.sha256(payload.encode('utf-8')).hexdigest()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def add_fingerprints(report):
|
|
444
|
+
for f in report['findings']:
|
|
445
|
+
f['fingerprint'] = fingerprint(f)
|
|
446
|
+
return report
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def apply_suppressions(report, config_path):
|
|
450
|
+
report['suppressed_findings'] = []
|
|
451
|
+
if not config_path.exists():
|
|
452
|
+
return report
|
|
453
|
+
try:
|
|
454
|
+
data = json.loads(config_path.read_text(encoding='utf-8'))
|
|
455
|
+
except (OSError, ValueError) as exc:
|
|
456
|
+
raise ValueError(f'Invalid suppression configuration {config_path}: {exc}') from exc
|
|
457
|
+
entries = data.get('suppressions', [])
|
|
458
|
+
if not isinstance(entries, list):
|
|
459
|
+
raise ValueError('suppressions must be a list')
|
|
460
|
+
for item in entries:
|
|
461
|
+
if not isinstance(item, dict) or not item.get('rule_id') or not str(item.get('reason','')).strip():
|
|
462
|
+
raise ValueError('Each suppression requires rule_id and nonempty reason')
|
|
463
|
+
retained = []
|
|
464
|
+
for f in report['findings']:
|
|
465
|
+
matched = next((x for x in entries if x['rule_id'] == f['rule_id'] and (not x.get('fingerprint') or x['fingerprint'] == f['fingerprint'])), None)
|
|
466
|
+
if matched:
|
|
467
|
+
f['suppression_reason'] = matched['reason']
|
|
468
|
+
report['suppressed_findings'].append(f)
|
|
469
|
+
else:
|
|
470
|
+
retained.append(f)
|
|
471
|
+
report['findings'] = retained
|
|
472
|
+
return report
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def add_baseline_diff(report, baseline_path):
|
|
476
|
+
if baseline_path is None:
|
|
477
|
+
report['baseline'] = None
|
|
478
|
+
for f in report['findings']:
|
|
479
|
+
f['status'] = 'unbaselined'
|
|
480
|
+
return report
|
|
481
|
+
try:
|
|
482
|
+
old = json.loads(baseline_path.read_text(encoding='utf-8'))
|
|
483
|
+
previous = {f.get('fingerprint') or fingerprint(f) for f in old['findings']}
|
|
484
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
485
|
+
raise ValueError(f'Invalid baseline JSON: {exc}') from exc
|
|
486
|
+
for f in report['findings']:
|
|
487
|
+
f['status'] = 'existing' if f['fingerprint'] in previous else 'new'
|
|
488
|
+
report['baseline'] = {'file': str(baseline_path), 'new_findings': sum(f['status']=='new' for f in report['findings'])}
|
|
489
|
+
return report
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def sarif_report(r):
|
|
493
|
+
# SARIF 2.1.0 subset; useful for IDE/CI ingestion. Most heuristic findings are not line-specific.
|
|
494
|
+
results=[]
|
|
495
|
+
for f in r['findings']:
|
|
496
|
+
results.append({
|
|
497
|
+
'ruleId': f['rule_id'], 'level': {'high':'error','medium':'warning','low':'note'}.get(f['severity'],'note'),
|
|
498
|
+
'message': {'text': f['title'] + '. Evidence: ' + f['evidence'] + '. Recommendation: ' + f['recommendation']},
|
|
499
|
+
'partialFingerprints': {'primaryLocationLineHash': f['fingerprint']},
|
|
500
|
+
'properties': {'confidence': f['confidence'], 'baselineStatus': f['status'], 'assessmentType': 'heuristic'},
|
|
501
|
+
})
|
|
502
|
+
return {'version':'2.1.0', '$schema':'https://json.schemastore.org/sarif-2.1.0.json', 'runs':[{
|
|
503
|
+
'tool':{'driver':{'name':'AQAF', 'version':__version__,
|
|
504
|
+
'rules':[{'id': k, 'name':k, 'shortDescription':{'text':v[1]}} for k,v in RULE_META.items()]}},
|
|
505
|
+
'results':results,
|
|
506
|
+
'properties':{'disclaimer':'Heuristic architecture hints, not security findings or a validated score.'}
|
|
507
|
+
}]}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def registry_command(argv):
|
|
511
|
+
ap=argparse.ArgumentParser(prog='aqaf '+argv[0],description='Inspect declarative profile/pack manifests')
|
|
512
|
+
ap.add_argument('action',choices=['list','show','validate'])
|
|
513
|
+
ap.add_argument('identifier',nargs='?',help='Profile ID / pack ID / JSON path')
|
|
514
|
+
ap.add_argument('--profile-dir',action='append',default=[])
|
|
515
|
+
ap.add_argument('--pack-dir',action='append',default=[])
|
|
516
|
+
args=ap.parse_args(argv[1:])
|
|
517
|
+
kind=argv[0]
|
|
518
|
+
if args.action=='validate':
|
|
519
|
+
if kind!='profiles' or not args.identifier:
|
|
520
|
+
ap.error('Only `profiles validate path/to/file.json` is supported')
|
|
521
|
+
try:
|
|
522
|
+
doc=read_doc(Path(args.identifier),'profile')
|
|
523
|
+
print(json.dumps({'valid':True,'id':doc['id']},indent=2))
|
|
524
|
+
except ProfileError as e: ap.error(str(e))
|
|
525
|
+
return 0
|
|
526
|
+
try:
|
|
527
|
+
reg=Registry(args.profile_dir,args.pack_dir)
|
|
528
|
+
store=reg.profiles if kind=='profiles' else reg.packs
|
|
529
|
+
if args.action=='show':
|
|
530
|
+
if not args.identifier: ap.error('show requires an ID')
|
|
531
|
+
if args.identifier not in store: raise ProfileError(f'Unknown {kind}: {args.identifier}')
|
|
532
|
+
print(json.dumps(store[args.identifier],indent=2))
|
|
533
|
+
else:
|
|
534
|
+
print(json.dumps([{'id':v['id'],'name':v['name'],'status':v.get('status','mixed_coverage'),
|
|
535
|
+
'coverage':v.get('coverage','mixed_coverage')} for v in sorted(store.values(),key=lambda d:d['id'])],indent=2))
|
|
536
|
+
except ProfileError as e: ap.error(str(e))
|
|
537
|
+
return 0
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def main(argv=None):
|
|
541
|
+
argv=list(sys.argv[1:] if argv is None else argv)
|
|
542
|
+
if argv and argv[0] in ('profiles','packs'): return registry_command(argv)
|
|
543
|
+
ap=argparse.ArgumentParser(description='AQAF local architecture scanner (heuristic, non-certifying)')
|
|
544
|
+
ap.add_argument('repo', nargs='?', default='.', help='Repository path')
|
|
545
|
+
ap.add_argument('--version', action='version', version='AQAF '+ __version__)
|
|
546
|
+
ap.add_argument('--format', choices=['md','json','sarif'], default='md')
|
|
547
|
+
ap.add_argument('--output','-o', default='-')
|
|
548
|
+
ap.add_argument('--profile', default='auto', help='Registry profile ID; see `aqaf profiles list`')
|
|
549
|
+
ap.add_argument('--pack', default='auto', help='Limit auto-detection to a pack; see `aqaf packs list`')
|
|
550
|
+
ap.add_argument('--profile-dir', action='append', default=[], help='Additional data-only JSON profile directory')
|
|
551
|
+
ap.add_argument('--pack-dir', action='append', default=[], help='Additional data-only JSON pack directory')
|
|
552
|
+
ap.add_argument('--capability', action='append', default=[], help='Declared contextual capability (repeatable)')
|
|
553
|
+
ap.add_argument('--baseline', help='Earlier AQAF JSON report for new/existing comparison')
|
|
554
|
+
ap.add_argument('--config', help='Path to local .aqaf.json suppression config; defaults to <repo>/.aqaf.json')
|
|
555
|
+
ap.add_argument('--fail-on', choices=['off','new-high','new-medium'], default='off', help='Optional CI gate for findings; requires a baseline')
|
|
556
|
+
args=ap.parse_args(argv)
|
|
557
|
+
repo=Path(args.repo).resolve()
|
|
558
|
+
if not repo.is_dir():
|
|
559
|
+
ap.error(f'Repository is not a directory: {repo}')
|
|
560
|
+
if args.fail_on!='off' and not args.baseline:
|
|
561
|
+
ap.error('--fail-on requires --baseline to avoid gating historic heuristic findings')
|
|
562
|
+
try:
|
|
563
|
+
registry=Registry(args.profile_dir,args.pack_dir)
|
|
564
|
+
if args.pack!='auto': registry.get_pack(args.pack)
|
|
565
|
+
r=add_fingerprints(scan(repo,args.profile,registry,args.pack,args.capability))
|
|
566
|
+
r=apply_suppressions(r,Path(args.config).resolve() if args.config else repo/'.aqaf.json')
|
|
567
|
+
r=add_baseline_diff(r,Path(args.baseline) if args.baseline else None)
|
|
568
|
+
except (ValueError, ProfileError) as exc:
|
|
569
|
+
ap.error(str(exc))
|
|
570
|
+
out=json.dumps(r,indent=2) if args.format=='json' else json.dumps(sarif_report(r),indent=2) if args.format=='sarif' else md_report(r)
|
|
571
|
+
if args.output=='-': print(out)
|
|
572
|
+
else:
|
|
573
|
+
dest=Path(args.output)
|
|
574
|
+
dest.parent.mkdir(parents=True,exist_ok=True)
|
|
575
|
+
dest.write_text(out,encoding='utf-8')
|
|
576
|
+
print(f'Wrote {dest}')
|
|
577
|
+
if args.fail_on != 'off':
|
|
578
|
+
allowed={'new-high':{'high'},'new-medium':{'high','medium'}}[args.fail_on]
|
|
579
|
+
if any(f['status']=='new' and f['severity'] in allowed for f in r['findings']):
|
|
580
|
+
return 2
|
|
581
|
+
return 0
|
|
582
|
+
|
|
583
|
+
if __name__ == '__main__': sys.exit(main())
|