oneport-depcheck 0.4.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.
- oneport_depcheck-0.4.0/PKG-INFO +8 -0
- oneport_depcheck-0.4.0/depcheck/__init__.py +5 -0
- oneport_depcheck-0.4.0/depcheck/audit_log.py +78 -0
- oneport_depcheck-0.4.0/depcheck/auth.py +207 -0
- oneport_depcheck-0.4.0/depcheck/cache.py +57 -0
- oneport_depcheck-0.4.0/depcheck/cicd.py +79 -0
- oneport_depcheck-0.4.0/depcheck/cli.py +748 -0
- oneport_depcheck-0.4.0/depcheck/container_scanner.py +246 -0
- oneport_depcheck-0.4.0/depcheck/dashboard.py +272 -0
- oneport_depcheck-0.4.0/depcheck/epss.py +64 -0
- oneport_depcheck-0.4.0/depcheck/github_advisory.py +95 -0
- oneport_depcheck-0.4.0/depcheck/hooks.py +195 -0
- oneport_depcheck-0.4.0/depcheck/integrations.py +221 -0
- oneport_depcheck-0.4.0/depcheck/license_checker.py +168 -0
- oneport_depcheck-0.4.0/depcheck/multi_repo.py +283 -0
- oneport_depcheck-0.4.0/depcheck/npm_scanner.py +117 -0
- oneport_depcheck-0.4.0/depcheck/nvd.py +107 -0
- oneport_depcheck-0.4.0/depcheck/policy.py +127 -0
- oneport_depcheck-0.4.0/depcheck/reachability.py +70 -0
- oneport_depcheck-0.4.0/depcheck/reporter.py +65 -0
- oneport_depcheck-0.4.0/depcheck/reporter_html.py +177 -0
- oneport_depcheck-0.4.0/depcheck/reporter_pdf.py +191 -0
- oneport_depcheck-0.4.0/depcheck/risk_scorer.py +129 -0
- oneport_depcheck-0.4.0/depcheck/sbom.py +93 -0
- oneport_depcheck-0.4.0/depcheck/sbom_diff.py +123 -0
- oneport_depcheck-0.4.0/depcheck/scanner.py +135 -0
- oneport_depcheck-0.4.0/depcheck/scheduler.py +132 -0
- oneport_depcheck-0.4.0/depcheck/slack_notify.py +137 -0
- oneport_depcheck-0.4.0/depcheck/supply_chain.py +146 -0
- oneport_depcheck-0.4.0/depcheck/suppress.py +164 -0
- oneport_depcheck-0.4.0/depcheck/transitive.py +87 -0
- oneport_depcheck-0.4.0/oneport_depcheck.egg-info/PKG-INFO +8 -0
- oneport_depcheck-0.4.0/oneport_depcheck.egg-info/SOURCES.txt +37 -0
- oneport_depcheck-0.4.0/oneport_depcheck.egg-info/dependency_links.txt +1 -0
- oneport_depcheck-0.4.0/oneport_depcheck.egg-info/entry_points.txt +2 -0
- oneport_depcheck-0.4.0/oneport_depcheck.egg-info/requires.txt +4 -0
- oneport_depcheck-0.4.0/oneport_depcheck.egg-info/top_level.txt +1 -0
- oneport_depcheck-0.4.0/setup.cfg +4 -0
- oneport_depcheck-0.4.0/setup.py +18 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
AUDIT_LOG_PATH = os.path.join(os.path.expanduser("~"), ".depcheck", "audit.jsonl")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _ensure_dir():
|
|
9
|
+
os.makedirs(os.path.dirname(AUDIT_LOG_PATH), exist_ok=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def log_scan(
|
|
13
|
+
packages_scanned: int,
|
|
14
|
+
findings: list[dict],
|
|
15
|
+
supply_chain: list[dict],
|
|
16
|
+
license_issues: list[dict],
|
|
17
|
+
policy_violations: list[dict],
|
|
18
|
+
scan_target: str = "unknown",
|
|
19
|
+
extra: dict = None,
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Append one scan record to the audit log. Returns the log path."""
|
|
22
|
+
_ensure_dir()
|
|
23
|
+
|
|
24
|
+
severity_counts = {}
|
|
25
|
+
for f in findings:
|
|
26
|
+
sev = f.get("severity", "UNKNOWN")
|
|
27
|
+
severity_counts[sev] = severity_counts.get(sev, 0) + 1
|
|
28
|
+
|
|
29
|
+
record = {
|
|
30
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
31
|
+
"scan_target": scan_target,
|
|
32
|
+
"packages_scanned": packages_scanned,
|
|
33
|
+
"findings_count": len(findings),
|
|
34
|
+
"severity_breakdown": severity_counts,
|
|
35
|
+
"supply_chain_count": len(supply_chain),
|
|
36
|
+
"license_issues_count": len(license_issues),
|
|
37
|
+
"policy_violations_count": len(policy_violations),
|
|
38
|
+
"cve_ids": [
|
|
39
|
+
cve for f in findings
|
|
40
|
+
for cve in f.get("ids", [])
|
|
41
|
+
if cve.startswith("CVE-")
|
|
42
|
+
][:20],
|
|
43
|
+
"extra": extra or {},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
with open(AUDIT_LOG_PATH, "a", encoding="utf-8") as f:
|
|
47
|
+
f.write(json.dumps(record) + "\n")
|
|
48
|
+
|
|
49
|
+
return AUDIT_LOG_PATH
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_audit_log(last_n: int = 20) -> list[dict]:
|
|
53
|
+
"""Read the last N scan records from the audit log."""
|
|
54
|
+
if not os.path.exists(AUDIT_LOG_PATH):
|
|
55
|
+
return []
|
|
56
|
+
records = []
|
|
57
|
+
try:
|
|
58
|
+
with open(AUDIT_LOG_PATH, encoding="utf-8") as f:
|
|
59
|
+
for line in f:
|
|
60
|
+
line = line.strip()
|
|
61
|
+
if line:
|
|
62
|
+
try:
|
|
63
|
+
records.append(json.loads(line))
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
pass
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return records[-last_n:]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def export_audit_log_pdf(output_path: str = "depcheck-audit.json") -> str:
|
|
72
|
+
"""Export full audit log as JSON for compliance teams."""
|
|
73
|
+
records = read_audit_log(last_n=9999)
|
|
74
|
+
with open(output_path, "w") as f:
|
|
75
|
+
json.dump({"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
76
|
+
"total_scans": len(records),
|
|
77
|
+
"scans": records}, f, indent=2)
|
|
78
|
+
return output_path
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import hashlib
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".depcheck")
|
|
9
|
+
AUTH_FILE = os.path.join(CONFIG_DIR, "auth.json")
|
|
10
|
+
KEYS_FILE = os.path.join(CONFIG_DIR, "api_keys.json")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ensure_dir():
|
|
14
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_auth() -> dict:
|
|
18
|
+
if not os.path.exists(AUTH_FILE):
|
|
19
|
+
return {}
|
|
20
|
+
with open(AUTH_FILE) as f:
|
|
21
|
+
return json.load(f)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _save_auth(data: dict):
|
|
25
|
+
_ensure_dir()
|
|
26
|
+
with open(AUTH_FILE, "w") as f:
|
|
27
|
+
json.dump(data, f, indent=2)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_keys() -> list[dict]:
|
|
31
|
+
if not os.path.exists(KEYS_FILE):
|
|
32
|
+
return []
|
|
33
|
+
with open(KEYS_FILE) as f:
|
|
34
|
+
return json.load(f)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _save_keys(keys: list[dict]):
|
|
38
|
+
_ensure_dir()
|
|
39
|
+
with open(KEYS_FILE, "w") as f:
|
|
40
|
+
json.dump(keys, f, indent=2)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def generate_api_key(name: str, scopes: list = None) -> dict:
|
|
44
|
+
raw_key = "dc_" + secrets.token_urlsafe(32)
|
|
45
|
+
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
|
46
|
+
|
|
47
|
+
entry = {
|
|
48
|
+
"id": secrets.token_hex(8),
|
|
49
|
+
"name": name,
|
|
50
|
+
"key_hash": key_hash,
|
|
51
|
+
"key_prefix": raw_key[:8],
|
|
52
|
+
"scopes": scopes or ["scan:read", "scan:write"],
|
|
53
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
54
|
+
"last_used": None,
|
|
55
|
+
"active": True,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
keys = _load_keys()
|
|
59
|
+
keys.append(entry)
|
|
60
|
+
_save_keys(keys)
|
|
61
|
+
|
|
62
|
+
result = dict(entry)
|
|
63
|
+
result["raw_key"] = raw_key
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_api_key(raw_key: str) -> dict | None:
|
|
68
|
+
if not raw_key.startswith("dc_"):
|
|
69
|
+
return None
|
|
70
|
+
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
|
71
|
+
keys = _load_keys()
|
|
72
|
+
for key in keys:
|
|
73
|
+
if key.get("key_hash") == key_hash and key.get("active"):
|
|
74
|
+
key["last_used"] = datetime.now(timezone.utc).isoformat()
|
|
75
|
+
_save_keys(keys)
|
|
76
|
+
return key
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def revoke_api_key(key_id: str) -> bool:
|
|
81
|
+
keys = _load_keys()
|
|
82
|
+
for key in keys:
|
|
83
|
+
if key["id"] == key_id:
|
|
84
|
+
key["active"] = False
|
|
85
|
+
_save_keys(keys)
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_api_keys() -> list[dict]:
|
|
91
|
+
keys = _load_keys()
|
|
92
|
+
return [
|
|
93
|
+
{k: v for k, v in key.items() if k != "key_hash"}
|
|
94
|
+
for key in keys
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def login_oidc(provider="okta", client_id=None,
|
|
99
|
+
issuer_url=None) -> dict:
|
|
100
|
+
import webbrowser
|
|
101
|
+
|
|
102
|
+
client_id = client_id or os.environ.get(
|
|
103
|
+
"DEPCHECK_OIDC_CLIENT_ID", ""
|
|
104
|
+
)
|
|
105
|
+
issuer_url = issuer_url or os.environ.get(
|
|
106
|
+
"DEPCHECK_OIDC_ISSUER", ""
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if not client_id or not issuer_url:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"Set DEPCHECK_OIDC_CLIENT_ID and DEPCHECK_OIDC_ISSUER.\n"
|
|
112
|
+
"Example:\n"
|
|
113
|
+
" export DEPCHECK_OIDC_CLIENT_ID=your-client-id\n"
|
|
114
|
+
" export DEPCHECK_OIDC_ISSUER=https://yourorg.okta.com"
|
|
115
|
+
"/oauth2/default"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
import requests as req
|
|
119
|
+
|
|
120
|
+
device_url = f"{issuer_url}/v1/device/authorize"
|
|
121
|
+
token_url = f"{issuer_url}/v1/token"
|
|
122
|
+
|
|
123
|
+
resp = req.post(device_url, data={
|
|
124
|
+
"client_id": client_id,
|
|
125
|
+
"scope": "openid profile email",
|
|
126
|
+
}, timeout=10)
|
|
127
|
+
|
|
128
|
+
if resp.status_code != 200:
|
|
129
|
+
raise RuntimeError(f"Device auth failed: {resp.text[:200]}")
|
|
130
|
+
|
|
131
|
+
data = resp.json()
|
|
132
|
+
device_code = data["device_code"]
|
|
133
|
+
user_code = data["user_code"]
|
|
134
|
+
verification_uri = data["verification_uri"]
|
|
135
|
+
interval = data.get("interval", 5)
|
|
136
|
+
expires_in = data.get("expires_in", 300)
|
|
137
|
+
|
|
138
|
+
print(f"\n Open: {verification_uri}")
|
|
139
|
+
print(f" Code: {user_code}\n")
|
|
140
|
+
webbrowser.open(verification_uri)
|
|
141
|
+
|
|
142
|
+
deadline = time.time() + expires_in
|
|
143
|
+
while time.time() < deadline:
|
|
144
|
+
time.sleep(interval)
|
|
145
|
+
token_resp = req.post(token_url, data={
|
|
146
|
+
"client_id": client_id,
|
|
147
|
+
"device_code": device_code,
|
|
148
|
+
"grant_type":
|
|
149
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
150
|
+
}, timeout=10)
|
|
151
|
+
|
|
152
|
+
token_data = token_resp.json()
|
|
153
|
+
if "access_token" in token_data:
|
|
154
|
+
auth = {
|
|
155
|
+
"provider": provider,
|
|
156
|
+
"access_token": token_data["access_token"],
|
|
157
|
+
"id_token": token_data.get("id_token"),
|
|
158
|
+
"expires_at": (
|
|
159
|
+
datetime.now(timezone.utc).timestamp()
|
|
160
|
+
+ token_data.get("expires_in", 3600)
|
|
161
|
+
),
|
|
162
|
+
"logged_in_at": datetime.now(timezone.utc).isoformat(),
|
|
163
|
+
}
|
|
164
|
+
_save_auth(auth)
|
|
165
|
+
return auth
|
|
166
|
+
|
|
167
|
+
error = token_data.get("error")
|
|
168
|
+
if error not in ("authorization_pending", "slow_down"):
|
|
169
|
+
raise RuntimeError(f"Auth error: {error}")
|
|
170
|
+
|
|
171
|
+
raise TimeoutError("Login timed out.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_current_auth() -> dict | None:
|
|
175
|
+
auth = _load_auth()
|
|
176
|
+
if not auth:
|
|
177
|
+
return None
|
|
178
|
+
if time.time() > auth.get("expires_at", 0):
|
|
179
|
+
return None
|
|
180
|
+
return auth
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def logout() -> bool:
|
|
184
|
+
if os.path.exists(AUTH_FILE):
|
|
185
|
+
os.remove(AUTH_FILE)
|
|
186
|
+
return True
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def require_auth(console=None) -> dict | None:
|
|
191
|
+
api_key = os.environ.get("DEPCHECK_API_KEY")
|
|
192
|
+
if api_key:
|
|
193
|
+
key_entry = validate_api_key(api_key)
|
|
194
|
+
if key_entry:
|
|
195
|
+
return {"type": "api_key", "name": key_entry["name"],
|
|
196
|
+
"scopes": key_entry["scopes"]}
|
|
197
|
+
if console:
|
|
198
|
+
console.print(
|
|
199
|
+
"[yellow]Warning: DEPCHECK_API_KEY invalid.[/yellow]"
|
|
200
|
+
)
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
auth = get_current_auth()
|
|
204
|
+
if auth:
|
|
205
|
+
return {"type": "oidc", "provider": auth.get("provider"),
|
|
206
|
+
"logged_in_at": auth.get("logged_in_at")}
|
|
207
|
+
return None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime, timezone, timedelta
|
|
5
|
+
|
|
6
|
+
CACHE_DIR = os.path.join(os.path.expanduser("~"), ".depcheck", "cache")
|
|
7
|
+
CACHE_TTL_HOURS = 24
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _cache_path(key: str) -> str:
|
|
11
|
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
12
|
+
hashed = hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
13
|
+
return os.path.join(CACHE_DIR, f"{hashed}.json")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cache_get(key: str) -> dict | None:
|
|
17
|
+
path = _cache_path(key)
|
|
18
|
+
if not os.path.exists(path):
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
with open(path) as f:
|
|
22
|
+
entry = json.load(f)
|
|
23
|
+
cached_at = datetime.fromisoformat(entry["cached_at"])
|
|
24
|
+
if datetime.now(timezone.utc) - cached_at > timedelta(hours=CACHE_TTL_HOURS):
|
|
25
|
+
os.remove(path)
|
|
26
|
+
return None
|
|
27
|
+
return entry["data"]
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cache_set(key: str, data) -> None:
|
|
33
|
+
path = _cache_path(key)
|
|
34
|
+
try:
|
|
35
|
+
with open(path, "w") as f:
|
|
36
|
+
json.dump({"cached_at": datetime.now(timezone.utc).isoformat(), "data": data}, f)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cache_clear() -> int:
|
|
42
|
+
if not os.path.exists(CACHE_DIR):
|
|
43
|
+
return 0
|
|
44
|
+
count = 0
|
|
45
|
+
for f in os.listdir(CACHE_DIR):
|
|
46
|
+
if f.endswith(".json"):
|
|
47
|
+
os.remove(os.path.join(CACHE_DIR, f))
|
|
48
|
+
count += 1
|
|
49
|
+
return count
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cache_stats() -> dict:
|
|
53
|
+
if not os.path.exists(CACHE_DIR):
|
|
54
|
+
return {"entries": 0, "size_kb": 0}
|
|
55
|
+
files = [f for f in os.listdir(CACHE_DIR) if f.endswith(".json")]
|
|
56
|
+
size = sum(os.path.getsize(os.path.join(CACHE_DIR, f)) for f in files)
|
|
57
|
+
return {"entries": len(files), "size_kb": round(size / 1024, 1)}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
GITHUB_ACTIONS_TEMPLATE = """\
|
|
2
|
+
name: Dependency Security Scan
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main, master]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [main, master]
|
|
9
|
+
schedule:
|
|
10
|
+
- cron: '0 9 * * 1' # Every Monday 9am UTC
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
depcheck:
|
|
14
|
+
name: oneport-depcheck
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout code
|
|
19
|
+
uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Set up Python
|
|
22
|
+
uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: '3.11'
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: pip install -r requirements.txt
|
|
28
|
+
|
|
29
|
+
- name: Install oneport-depcheck
|
|
30
|
+
run: pip install oneport-depcheck
|
|
31
|
+
|
|
32
|
+
- name: Run vulnerability scan
|
|
33
|
+
run: |
|
|
34
|
+
depcheck scan -r requirements.txt --fail-on CRITICAL,HIGH
|
|
35
|
+
|
|
36
|
+
- name: Generate SBOM
|
|
37
|
+
run: depcheck sbom -r requirements.txt
|
|
38
|
+
|
|
39
|
+
- name: Upload SBOM as artifact
|
|
40
|
+
uses: actions/upload-artifact@v4
|
|
41
|
+
with:
|
|
42
|
+
name: sbom
|
|
43
|
+
path: |
|
|
44
|
+
sbom.spdx.json
|
|
45
|
+
sbom.cyclonedx.json
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
GITLAB_CI_TEMPLATE = """\
|
|
49
|
+
depcheck:
|
|
50
|
+
stage: test
|
|
51
|
+
image: python:3.11
|
|
52
|
+
script:
|
|
53
|
+
- pip install -r requirements.txt
|
|
54
|
+
- pip install oneport-depcheck
|
|
55
|
+
- depcheck scan -r requirements.txt --fail-on CRITICAL,HIGH
|
|
56
|
+
- depcheck sbom -r requirements.txt
|
|
57
|
+
artifacts:
|
|
58
|
+
paths:
|
|
59
|
+
- sbom.spdx.json
|
|
60
|
+
- sbom.cyclonedx.json
|
|
61
|
+
expire_in: 30 days
|
|
62
|
+
only:
|
|
63
|
+
- main
|
|
64
|
+
- merge_requests
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def generate_github_actions(output_path: str = ".github/workflows/depcheck.yml") -> str:
|
|
69
|
+
import os
|
|
70
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
71
|
+
with open(output_path, "w") as f:
|
|
72
|
+
f.write(GITHUB_ACTIONS_TEMPLATE)
|
|
73
|
+
return output_path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def generate_gitlab_ci(output_path: str = "depcheck-gitlab.yml") -> str:
|
|
77
|
+
with open(output_path, "w") as f:
|
|
78
|
+
f.write(GITLAB_CI_TEMPLATE)
|
|
79
|
+
return output_path
|