oncecheck 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.
- oncecheck-0.1.0/LICENSE +21 -0
- oncecheck-0.1.0/MANIFEST.in +3 -0
- oncecheck-0.1.0/PKG-INFO +198 -0
- oncecheck-0.1.0/README.md +162 -0
- oncecheck-0.1.0/oncecheck/__init__.py +3 -0
- oncecheck-0.1.0/oncecheck/__main__.py +5 -0
- oncecheck-0.1.0/oncecheck/auth/__init__.py +0 -0
- oncecheck-0.1.0/oncecheck/auth/api.py +46 -0
- oncecheck-0.1.0/oncecheck/auth/login.py +141 -0
- oncecheck-0.1.0/oncecheck/auth/token_store.py +85 -0
- oncecheck-0.1.0/oncecheck/cli.py +416 -0
- oncecheck-0.1.0/oncecheck/engine/__init__.py +0 -0
- oncecheck-0.1.0/oncecheck/engine/config.py +123 -0
- oncecheck-0.1.0/oncecheck/engine/reporter.py +105 -0
- oncecheck-0.1.0/oncecheck/engine/runner.py +126 -0
- oncecheck-0.1.0/oncecheck/py.typed +0 -0
- oncecheck-0.1.0/oncecheck/rules/__init__.py +0 -0
- oncecheck-0.1.0/oncecheck/rules/android_rules.yaml +243 -0
- oncecheck-0.1.0/oncecheck/rules/common_rules.yaml +110 -0
- oncecheck-0.1.0/oncecheck/rules/ios_rules.yaml +267 -0
- oncecheck-0.1.0/oncecheck/rules/loader.py +87 -0
- oncecheck-0.1.0/oncecheck/rules/web_rules.yaml +325 -0
- oncecheck-0.1.0/oncecheck/scanners/__init__.py +0 -0
- oncecheck-0.1.0/oncecheck/scanners/android.py +516 -0
- oncecheck-0.1.0/oncecheck/scanners/common.py +308 -0
- oncecheck-0.1.0/oncecheck/scanners/detector.py +159 -0
- oncecheck-0.1.0/oncecheck/scanners/ios.py +504 -0
- oncecheck-0.1.0/oncecheck/scanners/web.py +554 -0
- oncecheck-0.1.0/oncecheck/ui/__init__.py +0 -0
- oncecheck-0.1.0/oncecheck/ui/interactive.py +166 -0
- oncecheck-0.1.0/oncecheck/ui/rich_console.py +293 -0
- oncecheck-0.1.0/oncecheck.egg-info/PKG-INFO +198 -0
- oncecheck-0.1.0/oncecheck.egg-info/SOURCES.txt +39 -0
- oncecheck-0.1.0/oncecheck.egg-info/dependency_links.txt +1 -0
- oncecheck-0.1.0/oncecheck.egg-info/entry_points.txt +2 -0
- oncecheck-0.1.0/oncecheck.egg-info/requires.txt +8 -0
- oncecheck-0.1.0/oncecheck.egg-info/top_level.txt +1 -0
- oncecheck-0.1.0/pyproject.toml +61 -0
- oncecheck-0.1.0/setup.cfg +4 -0
- oncecheck-0.1.0/tests/test_rules.py +167 -0
- oncecheck-0.1.0/tests/test_scans.py +572 -0
oncecheck-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oncecheck
|
|
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.
|
oncecheck-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oncecheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal-first CLI tool that scans iOS, Android, and Web projects for launch-critical compliance risks.
|
|
5
|
+
Author-email: Oncecheck <hello@oncecheck.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://oncecheck.com
|
|
8
|
+
Project-URL: Documentation, https://oncecheck.com
|
|
9
|
+
Project-URL: Repository, https://github.com/oncecheck/oncecheck-cli
|
|
10
|
+
Project-URL: Issues, https://github.com/oncecheck/oncecheck-cli/issues
|
|
11
|
+
Keywords: compliance,scanner,ios,android,web,app-store,play-store,owasp,privacy,accessibility
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: click>=8.1
|
|
29
|
+
Requires-Dist: rich>=13.0
|
|
30
|
+
Requires-Dist: pyyaml>=6.0
|
|
31
|
+
Requires-Dist: readchar>=4.0
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# Oncecheck CLI
|
|
38
|
+
|
|
39
|
+
A terminal-first CLI tool that scans iOS, Android, and Web projects for launch-critical compliance risks.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **73+ compliance rules** across iOS, Android, Web, and cross-platform categories
|
|
44
|
+
- **Auto-detection** — identifies your project type automatically
|
|
45
|
+
- **Interactive browser** — arrow-key driven UI to explore findings
|
|
46
|
+
- **Multiple export formats** — JSON, SARIF 2.1, plain text
|
|
47
|
+
- **CI/CD ready** — exit codes: 0 (pass), 1 (warnings), 2 (failures)
|
|
48
|
+
- **Cross-platform checks** — COPPA, HIPAA, PCI-DSS, accessibility, supply chain
|
|
49
|
+
- **Rule suppression** — `.oncecheckignore` file and `.oncecheckrc` config
|
|
50
|
+
- **Shell completions** — bash, zsh, and fish
|
|
51
|
+
|
|
52
|
+
## Rule Categories
|
|
53
|
+
|
|
54
|
+
| Platform | Rules | Covers |
|
|
55
|
+
|----------|-------|--------|
|
|
56
|
+
| iOS | 20 | Info.plist, ATS, entitlements, privacy manifests, HealthKit, Keychain |
|
|
57
|
+
| Android | 18 | AndroidManifest, target SDK, permissions, ProGuard, Play Integrity |
|
|
58
|
+
| Web | 27 | CSP, CORS, OWASP Top 10, accessibility, privacy, cookies |
|
|
59
|
+
| Common | 8 | COPPA, HIPAA, PCI-DSS, color contrast, data retention, supply chain |
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install oncecheck
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone <repo-url>
|
|
71
|
+
cd oncecheck-cli
|
|
72
|
+
python -m venv .venv
|
|
73
|
+
source .venv/bin/activate
|
|
74
|
+
pip install -e ".[dev]"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
### Interactive mode
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
oncecheck
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Launches the full interactive UI with welcome screen, menu navigation, and findings browser.
|
|
86
|
+
|
|
87
|
+
### Direct scan
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Auto-detect platform
|
|
91
|
+
oncecheck scan ./my-project
|
|
92
|
+
|
|
93
|
+
# Force a specific platform
|
|
94
|
+
oncecheck scan ./my-project --platform ios
|
|
95
|
+
|
|
96
|
+
# Export as JSON (Team plan)
|
|
97
|
+
oncecheck scan ./my-project --format json --output results.json
|
|
98
|
+
|
|
99
|
+
# Export as SARIF (Team plan — for GitHub/VS Code)
|
|
100
|
+
oncecheck scan ./my-project --format sarif --output results.sarif
|
|
101
|
+
|
|
102
|
+
# Interactive findings browser
|
|
103
|
+
oncecheck scan ./my-project --interactive
|
|
104
|
+
|
|
105
|
+
# Fail CI on warnings or higher
|
|
106
|
+
oncecheck scan ./my-project --fail-on WARN
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Authentication
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Sign in (opens browser)
|
|
113
|
+
oncecheck login
|
|
114
|
+
|
|
115
|
+
# Check status
|
|
116
|
+
oncecheck status
|
|
117
|
+
|
|
118
|
+
# Sign out
|
|
119
|
+
oncecheck logout
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Configuration
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# Generate config files in your project
|
|
126
|
+
oncecheck init ./my-project
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
This creates:
|
|
130
|
+
- `.oncecheckrc` — YAML config for disabled rules, severity overrides, and fail threshold
|
|
131
|
+
- `.oncecheckignore` — one rule ID per line to suppress
|
|
132
|
+
|
|
133
|
+
Example `.oncecheckrc`:
|
|
134
|
+
```yaml
|
|
135
|
+
disabled_rules:
|
|
136
|
+
- IOS-SEC-001
|
|
137
|
+
- WEB-OWASP-003
|
|
138
|
+
|
|
139
|
+
severity_overrides:
|
|
140
|
+
WEB-A11Y-001: INFO
|
|
141
|
+
|
|
142
|
+
fail_on: FAIL
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Shell Completions
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# Bash
|
|
149
|
+
oncecheck completions bash >> ~/.bashrc
|
|
150
|
+
|
|
151
|
+
# Zsh
|
|
152
|
+
oncecheck completions zsh >> ~/.zshrc
|
|
153
|
+
|
|
154
|
+
# Fish
|
|
155
|
+
oncecheck completions fish > ~/.config/fish/completions/oncecheck.fish
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## CI/CD Integration
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
# GitHub Actions example
|
|
162
|
+
- name: Compliance scan
|
|
163
|
+
run: |
|
|
164
|
+
pip install oncecheck
|
|
165
|
+
oncecheck login
|
|
166
|
+
oncecheck scan . --fail-on WARN
|
|
167
|
+
|
|
168
|
+
- name: Upload SARIF (Team plan)
|
|
169
|
+
run: oncecheck scan . --format sarif --output results.sarif
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Exit Codes
|
|
173
|
+
|
|
174
|
+
| Code | Meaning |
|
|
175
|
+
|------|---------|
|
|
176
|
+
| 0 | No issues (or only INFO) |
|
|
177
|
+
| 1 | Warnings found |
|
|
178
|
+
| 2 | Failures found |
|
|
179
|
+
|
|
180
|
+
## Plans
|
|
181
|
+
|
|
182
|
+
| Feature | Starter (Free) | Team ($19/mo) |
|
|
183
|
+
|---------|----------------|---------------|
|
|
184
|
+
| Scans per day | 3 | Unlimited |
|
|
185
|
+
| Terminal output | Yes | Yes |
|
|
186
|
+
| JSON/text export | — | Yes |
|
|
187
|
+
| SARIF export | — | Yes |
|
|
188
|
+
| File export (`--output`) | — | Yes |
|
|
189
|
+
|
|
190
|
+
## Testing
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
python -m pytest tests/ -v
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Oncecheck CLI
|
|
2
|
+
|
|
3
|
+
A terminal-first CLI tool that scans iOS, Android, and Web projects for launch-critical compliance risks.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **73+ compliance rules** across iOS, Android, Web, and cross-platform categories
|
|
8
|
+
- **Auto-detection** — identifies your project type automatically
|
|
9
|
+
- **Interactive browser** — arrow-key driven UI to explore findings
|
|
10
|
+
- **Multiple export formats** — JSON, SARIF 2.1, plain text
|
|
11
|
+
- **CI/CD ready** — exit codes: 0 (pass), 1 (warnings), 2 (failures)
|
|
12
|
+
- **Cross-platform checks** — COPPA, HIPAA, PCI-DSS, accessibility, supply chain
|
|
13
|
+
- **Rule suppression** — `.oncecheckignore` file and `.oncecheckrc` config
|
|
14
|
+
- **Shell completions** — bash, zsh, and fish
|
|
15
|
+
|
|
16
|
+
## Rule Categories
|
|
17
|
+
|
|
18
|
+
| Platform | Rules | Covers |
|
|
19
|
+
|----------|-------|--------|
|
|
20
|
+
| iOS | 20 | Info.plist, ATS, entitlements, privacy manifests, HealthKit, Keychain |
|
|
21
|
+
| Android | 18 | AndroidManifest, target SDK, permissions, ProGuard, Play Integrity |
|
|
22
|
+
| Web | 27 | CSP, CORS, OWASP Top 10, accessibility, privacy, cookies |
|
|
23
|
+
| Common | 8 | COPPA, HIPAA, PCI-DSS, color contrast, data retention, supply chain |
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install oncecheck
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Development
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone <repo-url>
|
|
35
|
+
cd oncecheck-cli
|
|
36
|
+
python -m venv .venv
|
|
37
|
+
source .venv/bin/activate
|
|
38
|
+
pip install -e ".[dev]"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Interactive mode
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
oncecheck
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Launches the full interactive UI with welcome screen, menu navigation, and findings browser.
|
|
50
|
+
|
|
51
|
+
### Direct scan
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Auto-detect platform
|
|
55
|
+
oncecheck scan ./my-project
|
|
56
|
+
|
|
57
|
+
# Force a specific platform
|
|
58
|
+
oncecheck scan ./my-project --platform ios
|
|
59
|
+
|
|
60
|
+
# Export as JSON (Team plan)
|
|
61
|
+
oncecheck scan ./my-project --format json --output results.json
|
|
62
|
+
|
|
63
|
+
# Export as SARIF (Team plan — for GitHub/VS Code)
|
|
64
|
+
oncecheck scan ./my-project --format sarif --output results.sarif
|
|
65
|
+
|
|
66
|
+
# Interactive findings browser
|
|
67
|
+
oncecheck scan ./my-project --interactive
|
|
68
|
+
|
|
69
|
+
# Fail CI on warnings or higher
|
|
70
|
+
oncecheck scan ./my-project --fail-on WARN
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Authentication
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Sign in (opens browser)
|
|
77
|
+
oncecheck login
|
|
78
|
+
|
|
79
|
+
# Check status
|
|
80
|
+
oncecheck status
|
|
81
|
+
|
|
82
|
+
# Sign out
|
|
83
|
+
oncecheck logout
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Configuration
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Generate config files in your project
|
|
90
|
+
oncecheck init ./my-project
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This creates:
|
|
94
|
+
- `.oncecheckrc` — YAML config for disabled rules, severity overrides, and fail threshold
|
|
95
|
+
- `.oncecheckignore` — one rule ID per line to suppress
|
|
96
|
+
|
|
97
|
+
Example `.oncecheckrc`:
|
|
98
|
+
```yaml
|
|
99
|
+
disabled_rules:
|
|
100
|
+
- IOS-SEC-001
|
|
101
|
+
- WEB-OWASP-003
|
|
102
|
+
|
|
103
|
+
severity_overrides:
|
|
104
|
+
WEB-A11Y-001: INFO
|
|
105
|
+
|
|
106
|
+
fail_on: FAIL
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Shell Completions
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Bash
|
|
113
|
+
oncecheck completions bash >> ~/.bashrc
|
|
114
|
+
|
|
115
|
+
# Zsh
|
|
116
|
+
oncecheck completions zsh >> ~/.zshrc
|
|
117
|
+
|
|
118
|
+
# Fish
|
|
119
|
+
oncecheck completions fish > ~/.config/fish/completions/oncecheck.fish
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## CI/CD Integration
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
# GitHub Actions example
|
|
126
|
+
- name: Compliance scan
|
|
127
|
+
run: |
|
|
128
|
+
pip install oncecheck
|
|
129
|
+
oncecheck login
|
|
130
|
+
oncecheck scan . --fail-on WARN
|
|
131
|
+
|
|
132
|
+
- name: Upload SARIF (Team plan)
|
|
133
|
+
run: oncecheck scan . --format sarif --output results.sarif
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Exit Codes
|
|
137
|
+
|
|
138
|
+
| Code | Meaning |
|
|
139
|
+
|------|---------|
|
|
140
|
+
| 0 | No issues (or only INFO) |
|
|
141
|
+
| 1 | Warnings found |
|
|
142
|
+
| 2 | Failures found |
|
|
143
|
+
|
|
144
|
+
## Plans
|
|
145
|
+
|
|
146
|
+
| Feature | Starter (Free) | Team ($19/mo) |
|
|
147
|
+
|---------|----------------|---------------|
|
|
148
|
+
| Scans per day | 3 | Unlimited |
|
|
149
|
+
| Terminal output | Yes | Yes |
|
|
150
|
+
| JSON/text export | — | Yes |
|
|
151
|
+
| SARIF export | — | Yes |
|
|
152
|
+
| File export (`--output`) | — | Yes |
|
|
153
|
+
|
|
154
|
+
## Testing
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
python -m pytest tests/ -v
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Server-side scan quota check via the Oncecheck API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import TypedDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
WEB_APP_URL = "https://oncecheck.com"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QuotaResult(TypedDict):
|
|
15
|
+
allowed: bool
|
|
16
|
+
plan: str
|
|
17
|
+
used: int
|
|
18
|
+
limit: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check_scan_quota(access_token: str) -> QuotaResult:
|
|
22
|
+
"""Check scan quota with the server and consume a scan slot if allowed.
|
|
23
|
+
|
|
24
|
+
Raises ConnectionError if the server is unreachable,
|
|
25
|
+
PermissionError if the token is invalid/expired.
|
|
26
|
+
"""
|
|
27
|
+
url = f"{WEB_APP_URL}/api/scan-check"
|
|
28
|
+
req = urllib.request.Request(
|
|
29
|
+
url,
|
|
30
|
+
method="POST",
|
|
31
|
+
headers={
|
|
32
|
+
"Authorization": f"Bearer {access_token}",
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
39
|
+
return json.loads(resp.read())
|
|
40
|
+
except urllib.error.HTTPError as exc:
|
|
41
|
+
if exc.code == 401:
|
|
42
|
+
raise PermissionError("Token expired or invalid. Run `oncecheck login` to re-authenticate.")
|
|
43
|
+
body = exc.read().decode(errors="replace")
|
|
44
|
+
raise ConnectionError(f"Scan check failed ({exc.code}): {body}")
|
|
45
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
46
|
+
raise ConnectionError(f"Could not reach Oncecheck server: {exc}")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""CLI authentication via localhost callback — opens browser, receives tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
import webbrowser
|
|
8
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from urllib.parse import urlparse, parse_qs
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from .token_store import load_token, save_token, clear_token
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
WEB_APP_URL = "https://oncecheck.com"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _find_free_port() -> int:
|
|
22
|
+
"""Find an available port on localhost."""
|
|
23
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
24
|
+
s.bind(("127.0.0.1", 0))
|
|
25
|
+
return s.getsockname()[1]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
29
|
+
"""Handles the OAuth callback from the web app."""
|
|
30
|
+
|
|
31
|
+
token_data: Optional[dict] = None
|
|
32
|
+
|
|
33
|
+
def do_GET(self) -> None:
|
|
34
|
+
parsed = urlparse(self.path)
|
|
35
|
+
if parsed.path != "/callback":
|
|
36
|
+
self.send_response(404)
|
|
37
|
+
self.end_headers()
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
params = parse_qs(parsed.query)
|
|
41
|
+
access_token = params.get("access_token", [None])[0]
|
|
42
|
+
refresh_token = params.get("refresh_token", [None])[0]
|
|
43
|
+
email = params.get("email", [None])[0]
|
|
44
|
+
plan = params.get("plan", ["starter"])[0]
|
|
45
|
+
|
|
46
|
+
if not access_token:
|
|
47
|
+
self.send_response(400)
|
|
48
|
+
self.send_header("Content-Type", "text/html")
|
|
49
|
+
self.end_headers()
|
|
50
|
+
self.wfile.write(b"<html><body><h2>Authentication failed.</h2>"
|
|
51
|
+
b"<p>No token received. Please try again.</p></body></html>")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Store on the class so the caller can retrieve it
|
|
55
|
+
_CallbackHandler.token_data = {
|
|
56
|
+
"access_token": access_token,
|
|
57
|
+
"refresh_token": refresh_token or "",
|
|
58
|
+
"email": email or "",
|
|
59
|
+
"plan": plan,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
self.send_response(200)
|
|
63
|
+
self.send_header("Content-Type", "text/html")
|
|
64
|
+
self.end_headers()
|
|
65
|
+
self.wfile.write(
|
|
66
|
+
b"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
|
|
67
|
+
b"<h2 style='color:#22c55e'>Authenticated!</h2>"
|
|
68
|
+
b"<p>You can close this tab and return to the terminal.</p>"
|
|
69
|
+
b"</body></html>"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Shutdown the server in a separate thread to avoid deadlock
|
|
73
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
74
|
+
|
|
75
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
76
|
+
"""Suppress default HTTP logging."""
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def login_flow() -> dict:
|
|
81
|
+
"""Run the browser-based login flow with localhost callback.
|
|
82
|
+
|
|
83
|
+
1. Starts a temporary HTTP server on a free port.
|
|
84
|
+
2. Opens the web app login page with a cli_redirect parameter.
|
|
85
|
+
3. Waits for the browser to redirect back with tokens.
|
|
86
|
+
4. Saves the tokens and returns the token data.
|
|
87
|
+
"""
|
|
88
|
+
port = _find_free_port()
|
|
89
|
+
redirect_url = f"http://localhost:{port}/callback"
|
|
90
|
+
login_url = f"{WEB_APP_URL}/login?cli_redirect={redirect_url}"
|
|
91
|
+
|
|
92
|
+
_CallbackHandler.token_data = None
|
|
93
|
+
|
|
94
|
+
server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
|
|
95
|
+
|
|
96
|
+
console.print()
|
|
97
|
+
console.print("[bold yellow]Authentication required.[/bold yellow]")
|
|
98
|
+
console.print("Opening your browser to sign in...\n")
|
|
99
|
+
console.print(f" [bold cyan]{login_url}[/bold cyan]\n")
|
|
100
|
+
console.print("[dim]Waiting for authentication...[/dim]")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
webbrowser.open(login_url)
|
|
104
|
+
except OSError:
|
|
105
|
+
console.print("[dim]Could not open browser. Please visit the URL above manually.[/dim]")
|
|
106
|
+
|
|
107
|
+
# Run server in background thread with a 5-minute timeout
|
|
108
|
+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
109
|
+
server_thread.start()
|
|
110
|
+
server_thread.join(timeout=300)
|
|
111
|
+
|
|
112
|
+
if server_thread.is_alive():
|
|
113
|
+
server.shutdown()
|
|
114
|
+
server.server_close()
|
|
115
|
+
console.print("\n[bold red]Login timed out.[/bold red] Please try again.")
|
|
116
|
+
raise SystemExit(1)
|
|
117
|
+
|
|
118
|
+
server.server_close()
|
|
119
|
+
|
|
120
|
+
token_data = _CallbackHandler.token_data
|
|
121
|
+
if not token_data or not token_data.get("access_token"):
|
|
122
|
+
console.print("[bold red]Authentication failed.[/bold red] No token received.")
|
|
123
|
+
raise SystemExit(1)
|
|
124
|
+
|
|
125
|
+
save_token(token_data)
|
|
126
|
+
console.print(f"[bold green]Authenticated![/bold green] ({token_data.get('email', '')} — {token_data.get('plan', 'starter').capitalize()} plan)")
|
|
127
|
+
return token_data
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def ensure_authenticated() -> dict:
|
|
131
|
+
"""Return token data if already authenticated, or trigger login flow."""
|
|
132
|
+
token = load_token()
|
|
133
|
+
if token and token.get("access_token"):
|
|
134
|
+
return token
|
|
135
|
+
return login_flow()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def logout() -> None:
|
|
139
|
+
"""Clear stored credentials."""
|
|
140
|
+
clear_token()
|
|
141
|
+
console.print("[green]Logged out successfully.[/green]")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Persistent token storage for CLI authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import stat
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
APP_NAME = "oncecheck"
|
|
15
|
+
TOKEN_TTL_SECONDS = 7 * 24 * 3600 # 7 days
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _token_dir() -> Path:
|
|
19
|
+
system = platform.system()
|
|
20
|
+
if system == "Darwin":
|
|
21
|
+
base = Path.home() / "Library" / "Application Support"
|
|
22
|
+
elif system == "Windows":
|
|
23
|
+
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
|
|
24
|
+
else:
|
|
25
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
26
|
+
return base / APP_NAME
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _token_path() -> Path:
|
|
30
|
+
return _token_dir() / "token.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _set_file_permissions(path: Path, is_dir: bool = False) -> None:
|
|
34
|
+
"""Restrict to owner-only: 0o700 for dirs, 0o600 for files."""
|
|
35
|
+
try:
|
|
36
|
+
if platform.system() != "Windows":
|
|
37
|
+
if is_dir:
|
|
38
|
+
os.chmod(path, stat.S_IRWXU) # 0o700
|
|
39
|
+
else:
|
|
40
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0o600
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_token(data: dict) -> Path:
|
|
46
|
+
"""Write token data to disk with restricted permissions and return the path."""
|
|
47
|
+
# Stamp the token with an issued-at time for expiration checks
|
|
48
|
+
if "issued_at" not in data:
|
|
49
|
+
data["issued_at"] = time.time()
|
|
50
|
+
|
|
51
|
+
path = _token_path()
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
# Set restrictive permissions on directory (needs execute for traversal)
|
|
55
|
+
_set_file_permissions(path.parent, is_dir=True)
|
|
56
|
+
|
|
57
|
+
path.write_text(json.dumps(data, indent=2))
|
|
58
|
+
_set_file_permissions(path)
|
|
59
|
+
return path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_token() -> Optional[dict]:
|
|
63
|
+
"""Load saved token, or return None if absent / invalid / expired."""
|
|
64
|
+
path = _token_path()
|
|
65
|
+
if not path.exists():
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(path.read_text())
|
|
69
|
+
except (json.JSONDecodeError, OSError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
# Check token expiration
|
|
73
|
+
issued_at = data.get("issued_at", 0)
|
|
74
|
+
if time.time() - issued_at > TOKEN_TTL_SECONDS:
|
|
75
|
+
clear_token()
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
return data
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def clear_token() -> None:
|
|
82
|
+
"""Delete the saved token file."""
|
|
83
|
+
path = _token_path()
|
|
84
|
+
if path.exists():
|
|
85
|
+
path.unlink()
|