oki692 commited on
Commit
2bb1e37
·
verified ·
1 Parent(s): a31b96f

Add missing scripts folder

Browse files
Files changed (2) hide show
  1. scripts/generate-sbom.sh +194 -0
  2. scripts/prepare-pyodide.js +201 -0
scripts/generate-sbom.sh ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # generate-sbom.sh — Generate a clean CycloneDX SBOM using Syft
4
+ #
5
+ # Produces a single SBOM from resolved manifests only — no directory scanning,
6
+ # no venv pollution, no local state. Works identically locally and in CI.
7
+ #
8
+ # How it works:
9
+ # 1. Python: uv pip compile resolves all transitive deps from requirements.txt
10
+ # 2. JavaScript: package-lock.json already contains the full resolved tree
11
+ # 3. Syft scans these resolved files, not the filesystem
12
+ #
13
+ # Usage:
14
+ # ./scripts/generate-sbom.sh # generate sbom.cdx.json from manifests
15
+ # ./scripts/generate-sbom.sh docker # generate from Docker image (best license coverage)
16
+ # ./scripts/generate-sbom.sh docker IMG # generate from a specific image
17
+ # ./scripts/generate-sbom.sh validate # validate existing SBOM
18
+ #
19
+ # Requirements:
20
+ # - syft (brew install syft)
21
+ # - uv (brew install uv)
22
+ #
23
+
24
+ set -euo pipefail
25
+
26
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
+ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
28
+
29
+ RED='\033[0;31m'
30
+ GREEN='\033[0;32m'
31
+ DIM='\033[2m'
32
+ BOLD='\033[1m'
33
+ RESET='\033[0m'
34
+
35
+ info() { echo -e "${BOLD}${GREEN}▸${RESET} $1"; }
36
+ warn() { echo -e "${BOLD}${RED}▸${RESET} $1"; }
37
+ dim() { echo -e "${DIM} $1${RESET}"; }
38
+
39
+ OUTPUT="$ROOT_DIR/sbom.cdx.json"
40
+
41
+ check_deps() {
42
+ local missing=()
43
+ command -v syft &>/dev/null || missing+=("syft")
44
+ command -v uv &>/dev/null || missing+=("uv")
45
+ if [[ ${#missing[@]} -gt 0 ]]; then
46
+ warn "Missing: ${missing[*]}. Install with: brew install ${missing[*]}"
47
+ exit 1
48
+ fi
49
+ dim "Using $(syft --version), $(uv --version)"
50
+ }
51
+
52
+ generate() {
53
+ info "Generating SBOM from resolved manifests..."
54
+ check_deps
55
+
56
+ local VERSION
57
+ VERSION="$(python3 -c "import json; print(json.load(open('$ROOT_DIR/package.json'))['version'])")"
58
+
59
+ local WORK_DIR
60
+ WORK_DIR="$(mktemp -d)"
61
+ trap 'rm -rf "$WORK_DIR"' RETURN
62
+
63
+ # --- Python: resolve all transitive deps without installing ---
64
+ dim "Resolving Python transitive deps (uv pip compile)..."
65
+ uv pip compile "$ROOT_DIR/backend/requirements.txt" \
66
+ --python-version 3.11 \
67
+ --quiet \
68
+ > "$WORK_DIR/requirements-resolved.txt" 2>/dev/null
69
+
70
+ # --- JavaScript: package-lock.json is already fully resolved ---
71
+ if [[ -f "$ROOT_DIR/package-lock.json" ]]; then
72
+ cp "$ROOT_DIR/package-lock.json" "$WORK_DIR/package-lock.json"
73
+ # Syft needs package.json alongside the lockfile
74
+ cp "$ROOT_DIR/package.json" "$WORK_DIR/package.json"
75
+ else
76
+ warn "package-lock.json not found — JS deps will be skipped"
77
+ fi
78
+
79
+ # --- Scan only the resolved files ---
80
+ dim "Scanning resolved manifests with Syft..."
81
+ syft scan "dir:$WORK_DIR" \
82
+ --output "cyclonedx-json=$OUTPUT" \
83
+ --source-name open-webui \
84
+ --source-version "$VERSION" \
85
+ --quiet
86
+
87
+ # Print summary
88
+ python3 -c "
89
+ import json
90
+ with open('$OUTPUT') as f:
91
+ data = json.load(f)
92
+ comps = data.get('components', [])
93
+ py = [c for c in comps if 'pypi' in c.get('purl', '')]
94
+ js = [c for c in comps if 'npm' in c.get('purl', '')]
95
+ with_lic = sum(1 for c in comps if c.get('licenses'))
96
+ print(f' {len(comps)} total ({len(py)} Python, {len(js)} JavaScript)')
97
+ print(f' {with_lic}/{len(comps)} with license info')
98
+ print(f' Serial: {data.get(\"serialNumber\", \"none\")}')
99
+ print(f' Timestamp: {data.get(\"metadata\", {}).get(\"timestamp\", \"none\")}')
100
+ "
101
+
102
+ info "SBOM written → sbom.cdx.json"
103
+ }
104
+
105
+ generate_docker() {
106
+ local IMAGE="${1:-ghcr.io/open-webui/open-webui:latest}"
107
+ info "Generating SBOM from Docker image: $IMAGE"
108
+
109
+ if ! command -v syft &>/dev/null; then
110
+ warn "syft is not installed. Install with: brew install syft"
111
+ exit 1
112
+ fi
113
+
114
+ dim "Pulling and scanning image..."
115
+ syft scan "docker:$IMAGE" \
116
+ --output "cyclonedx-json=$OUTPUT" \
117
+ --quiet
118
+
119
+ python3 -c "
120
+ import json
121
+ with open('$OUTPUT') as f:
122
+ data = json.load(f)
123
+ comps = data.get('components', [])
124
+ with_lic = sum(1 for c in comps if c.get('licenses'))
125
+ print(f' {len(comps)} total components')
126
+ print(f' {with_lic}/{len(comps)} with license info ({round(with_lic/max(len(comps),1)*100)}%)')
127
+ "
128
+
129
+ info "SBOM written → sbom.cdx.json"
130
+ }
131
+
132
+ validate() {
133
+ info "Validating SBOM..."
134
+
135
+ python3 -c "
136
+ import json, sys
137
+
138
+ try:
139
+ with open('$OUTPUT') as f:
140
+ data = json.load(f)
141
+ except FileNotFoundError:
142
+ print(' ✗ sbom.cdx.json not found — run ./scripts/generate-sbom.sh first')
143
+ sys.exit(1)
144
+
145
+ issues = []
146
+ if data.get('bomFormat') != 'CycloneDX':
147
+ issues.append('Not CycloneDX format')
148
+ if not data.get('specVersion'):
149
+ issues.append('Missing specVersion')
150
+ if not data.get('serialNumber'):
151
+ issues.append('Missing serial number')
152
+
153
+ components = data.get('components', [])
154
+
155
+ # Check for phantom local packages
156
+ phantoms = []
157
+ for c in components:
158
+ for ref in c.get('externalReferences', []):
159
+ url = ref.get('url', '')
160
+ if 'file://' in url and '/Users/' in url:
161
+ phantoms.append(c['name'])
162
+ if phantoms:
163
+ issues.append(f'Phantom local packages: {phantoms}')
164
+
165
+ with_lic = sum(1 for c in components if c.get('licenses'))
166
+ lic_pct = round(with_lic / max(len(components), 1) * 100)
167
+
168
+ if issues:
169
+ print(f' ✗ {len(components)} components, {lic_pct}% licensed')
170
+ for i in issues:
171
+ print(f' ✗ {i}')
172
+ sys.exit(1)
173
+ else:
174
+ print(f' ✓ {len(components)} components, {lic_pct}% licensed — PASS')
175
+ "
176
+ }
177
+
178
+ # --- Main ---
179
+ cd "$ROOT_DIR"
180
+ TARGET="${1:-generate}"
181
+
182
+ case "$TARGET" in
183
+ generate) generate ;;
184
+ docker) generate_docker "${2:-}" ;;
185
+ validate) validate ;;
186
+ *)
187
+ warn "Unknown target: $TARGET"
188
+ echo "Usage: $0 [generate|docker [IMAGE]|validate]"
189
+ exit 1
190
+ ;;
191
+ esac
192
+
193
+ echo ""
194
+ info "Done."
scripts/prepare-pyodide.js ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const packages = [
2
+ 'micropip',
3
+ 'packaging',
4
+ 'requests',
5
+ 'beautifulsoup4',
6
+ 'numpy',
7
+ 'pandas',
8
+ 'matplotlib',
9
+ 'scikit-learn',
10
+ 'scipy',
11
+ 'regex',
12
+ 'sympy',
13
+ 'tiktoken',
14
+ 'seaborn',
15
+ 'pytz',
16
+ 'black',
17
+ 'openai',
18
+ 'openpyxl'
19
+ ];
20
+
21
+ // Pure-Python packages whose wheels must be downloaded from PyPI and saved into
22
+ // static/pyodide/ so that the browser can install them offline via micropip.
23
+ // Packages already provided by the Pyodide distribution (click, platformdirs,
24
+ // typing_extensions, etc.) do NOT need to be listed here.
25
+ const pypiPackages = ['black', 'pathspec', 'mypy_extensions', 'pytokens'];
26
+
27
+ import { loadPyodide } from 'pyodide';
28
+ import { setGlobalDispatcher, ProxyAgent } from 'undici';
29
+ import { writeFile, readFile, copyFile, readdir, rmdir, access } from 'fs/promises';
30
+
31
+ /**
32
+ * Loading network proxy configurations from the environment variables.
33
+ * And the proxy config with lowercase name has the highest priority to use.
34
+ */
35
+ function initNetworkProxyFromEnv() {
36
+ // we assume all subsequent requests in this script are HTTPS:
37
+ // https://cdn.jsdelivr.net
38
+ // https://pypi.org
39
+ // https://files.pythonhosted.org
40
+ const allProxy = process.env.all_proxy || process.env.ALL_PROXY;
41
+ const httpsProxy = process.env.https_proxy || process.env.HTTPS_PROXY;
42
+ const httpProxy = process.env.http_proxy || process.env.HTTP_PROXY;
43
+ const preferedProxy = httpsProxy || allProxy || httpProxy;
44
+ /**
45
+ * use only http(s) proxy because socks5 proxy is not supported currently:
46
+ * @see https://github.com/nodejs/undici/issues/2224
47
+ */
48
+ if (!preferedProxy || !preferedProxy.startsWith('http')) return;
49
+ let preferedProxyURL;
50
+ try {
51
+ preferedProxyURL = new URL(preferedProxy).toString();
52
+ } catch {
53
+ console.warn(`Invalid network proxy URL: "${preferedProxy}"`);
54
+ return;
55
+ }
56
+ const dispatcher = new ProxyAgent({ uri: preferedProxyURL });
57
+ setGlobalDispatcher(dispatcher);
58
+ console.log(`Initialized network proxy "${preferedProxy}" from env`);
59
+ }
60
+
61
+ async function downloadPackages() {
62
+ console.log('Setting up pyodide + micropip');
63
+
64
+ let pyodide;
65
+ try {
66
+ pyodide = await loadPyodide({
67
+ packageCacheDir: 'static/pyodide'
68
+ });
69
+ } catch (err) {
70
+ console.error('Failed to load Pyodide:', err);
71
+ return;
72
+ }
73
+
74
+ const packageJson = JSON.parse(await readFile('package.json'));
75
+ const pyodideVersion = packageJson.dependencies.pyodide.replace('^', '');
76
+
77
+ try {
78
+ const pyodidePackageJson = JSON.parse(await readFile('static/pyodide/package.json'));
79
+ const pyodidePackageVersion = pyodidePackageJson.version.replace('^', '');
80
+
81
+ if (pyodideVersion !== pyodidePackageVersion) {
82
+ console.log('Pyodide version mismatch, removing static/pyodide directory');
83
+ await rmdir('static/pyodide', { recursive: true });
84
+ }
85
+ } catch (err) {
86
+ console.log('Pyodide package not found, proceeding with download.', err);
87
+ }
88
+
89
+ try {
90
+ console.log('Loading micropip package');
91
+ await pyodide.loadPackage('micropip');
92
+
93
+ const micropip = pyodide.pyimport('micropip');
94
+ console.log('Downloading Pyodide packages:', packages);
95
+
96
+ try {
97
+ for (const pkg of packages) {
98
+ console.log(`Installing package: ${pkg}`);
99
+ await micropip.install(pkg);
100
+ }
101
+ } catch (err) {
102
+ console.error('Package installation failed:', err);
103
+ return;
104
+ }
105
+
106
+ console.log('Pyodide packages downloaded, freezing into lock file');
107
+
108
+ try {
109
+ const lockFile = await micropip.freeze();
110
+ await writeFile('static/pyodide/pyodide-lock.json', lockFile);
111
+ } catch (err) {
112
+ console.error('Failed to write lock file:', err);
113
+ }
114
+ } catch (err) {
115
+ console.error('Failed to load or install micropip:', err);
116
+ }
117
+ }
118
+
119
+ async function copyPyodide() {
120
+ console.log('Copying Pyodide files into static directory');
121
+ // Copy all files from node_modules/pyodide to static/pyodide
122
+ for await (const entry of await readdir('node_modules/pyodide')) {
123
+ await copyFile(`node_modules/pyodide/${entry}`, `static/pyodide/${entry}`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Download pure-Python wheels from PyPI and save them into static/pyodide/.
129
+ * Also injects entries into pyodide-lock.json so that micropip resolves these
130
+ * packages from the local server instead of fetching them from the internet.
131
+ */
132
+ async function downloadPyPIWheels() {
133
+ const lockPath = 'static/pyodide/pyodide-lock.json';
134
+ let lockData;
135
+ try {
136
+ lockData = JSON.parse(await readFile(lockPath, 'utf-8'));
137
+ } catch {
138
+ console.warn('Could not read pyodide-lock.json, skipping PyPI wheel download');
139
+ return;
140
+ }
141
+
142
+ for (const pkg of pypiPackages) {
143
+ console.log(`Fetching PyPI metadata for: ${pkg}`);
144
+ const res = await fetch(`https://pypi.org/pypi/${pkg}/json`);
145
+ if (!res.ok) {
146
+ console.error(`Failed to fetch PyPI metadata for ${pkg}: ${res.status}`);
147
+ continue;
148
+ }
149
+ const meta = await res.json();
150
+ const version = meta.info.version;
151
+ const files = meta.urls || [];
152
+ // Find the pure-Python wheel (py3-none-any)
153
+ const wheel = files.find(
154
+ (f) => f.filename.endsWith('.whl') && f.filename.includes('py3-none-any')
155
+ );
156
+ if (!wheel) {
157
+ console.warn(`No pure-Python wheel found for ${pkg}==${version}, skipping`);
158
+ continue;
159
+ }
160
+ const dest = `static/pyodide/${wheel.filename}`;
161
+ // Download wheel if not already present
162
+ try {
163
+ await access(dest);
164
+ console.log(` Already exists: ${wheel.filename}`);
165
+ } catch {
166
+ console.log(` Downloading: ${wheel.filename}`);
167
+ const wheelRes = await fetch(wheel.url);
168
+ if (!wheelRes.ok) {
169
+ console.error(` Failed to download ${wheel.filename}: ${wheelRes.status}`);
170
+ continue;
171
+ }
172
+ const buffer = Buffer.from(await wheelRes.arrayBuffer());
173
+ await writeFile(dest, buffer);
174
+ console.log(` Saved: ${dest} (${buffer.length} bytes)`);
175
+ }
176
+
177
+ // Inject into pyodide-lock.json so micropip resolves locally
178
+ const normalizedName = pkg.replace(/-/g, '_');
179
+ if (!lockData.packages[normalizedName]) {
180
+ lockData.packages[normalizedName] = {
181
+ name: normalizedName,
182
+ version: version,
183
+ file_name: wheel.filename,
184
+ install_dir: 'site',
185
+ sha256: wheel.digests?.sha256 || '',
186
+ package_type: 'package',
187
+ imports: [normalizedName],
188
+ depends: []
189
+ };
190
+ console.log(` Added ${normalizedName}==${version} to pyodide-lock.json`);
191
+ }
192
+ }
193
+
194
+ await writeFile(lockPath, JSON.stringify(lockData, null, 2));
195
+ console.log('Updated pyodide-lock.json with PyPI packages');
196
+ }
197
+
198
+ initNetworkProxyFromEnv();
199
+ await downloadPackages();
200
+ await copyPyodide();
201
+ await downloadPyPIWheels();