opencode-skills-collection 3.1.5 → 3.1.7

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.
Files changed (36) hide show
  1. package/bundled-skills/.antigravity-install-manifest.json +4 -1
  2. package/bundled-skills/007/scripts/scanners/dependency_scanner.py +2 -2
  3. package/bundled-skills/docs/integrations/jetski-cortex.md +3 -3
  4. package/bundled-skills/docs/integrations/jetski-gemini-loader/README.md +1 -1
  5. package/bundled-skills/docs/maintainers/repo-growth-seo.md +3 -3
  6. package/bundled-skills/docs/maintainers/skills-update-guide.md +1 -1
  7. package/bundled-skills/docs/users/bundles.md +1 -1
  8. package/bundled-skills/docs/users/claude-code-skills.md +1 -1
  9. package/bundled-skills/docs/users/gemini-cli-skills.md +1 -1
  10. package/bundled-skills/docs/users/getting-started.md +1 -1
  11. package/bundled-skills/docs/users/kiro-integration.md +1 -1
  12. package/bundled-skills/docs/users/usage.md +4 -4
  13. package/bundled-skills/docs/users/visual-guide.md +4 -4
  14. package/bundled-skills/docx-official/ooxml/scripts/pack.py +12 -0
  15. package/bundled-skills/docx-official/ooxml/scripts/unpack.py +28 -7
  16. package/bundled-skills/docx-official/ooxml/scripts/validation/base.py +18 -13
  17. package/bundled-skills/docx-official/ooxml/scripts/validation/docx.py +6 -6
  18. package/bundled-skills/docx-official/ooxml/scripts/validation/pptx.py +6 -6
  19. package/bundled-skills/emil-design-eng/SKILL.md +702 -0
  20. package/bundled-skills/hugging-face-model-trainer/scripts/convert_to_gguf.py +3 -2
  21. package/bundled-skills/infinity/SKILL.md +173 -0
  22. package/bundled-skills/loki-mode/autonomy/run.sh +17 -8
  23. package/bundled-skills/loki-mode/examples/todo-app-generated/backend/package-lock.json +17 -13
  24. package/bundled-skills/loki-mode/examples/todo-app-generated/backend/package.json +3 -3
  25. package/bundled-skills/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh +11 -8
  26. package/bundled-skills/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh +10 -5
  27. package/bundled-skills/pptx-official/ooxml/scripts/pack.py +12 -0
  28. package/bundled-skills/pptx-official/ooxml/scripts/unpack.py +28 -7
  29. package/bundled-skills/pptx-official/ooxml/scripts/validation/base.py +18 -13
  30. package/bundled-skills/pptx-official/ooxml/scripts/validation/docx.py +6 -6
  31. package/bundled-skills/pptx-official/ooxml/scripts/validation/pptx.py +6 -6
  32. package/bundled-skills/review-animations/SKILL.md +139 -0
  33. package/bundled-skills/review-animations/STANDARDS.md +188 -0
  34. package/bundled-skills/skill-creator/scripts/package_skill.py +11 -1
  35. package/package.json +2 -2
  36. package/skills_index.json +66 -0
@@ -132,6 +132,7 @@ ADAPTER_MODEL = require_hf_id(os.environ.get("ADAPTER_MODEL", "evalstate/qwen-ca
132
132
  BASE_MODEL = require_hf_id(os.environ.get("BASE_MODEL", "Qwen/Qwen2.5-0.5B"), "BASE_MODEL")
133
133
  OUTPUT_REPO = require_hf_id(os.environ.get("OUTPUT_REPO", "evalstate/qwen-capybara-medium-gguf"), "OUTPUT_REPO")
134
134
  username = require_hf_id(os.environ.get("HF_USERNAME", ADAPTER_MODEL.split('/')[0]), "HF_USERNAME")
135
+ TRUST_REMOTE_CODE = os.environ.get("TRUST_REMOTE_CODE", "").strip().lower() in {"1", "true", "yes"}
135
136
 
136
137
  print(f"\nšŸ“¦ Configuration:")
137
138
  print(f" Base model: {BASE_MODEL}")
@@ -147,7 +148,7 @@ try:
147
148
  BASE_MODEL,
148
149
  dtype=torch.float16,
149
150
  device_map="auto",
150
- trust_remote_code=True,
151
+ trust_remote_code=TRUST_REMOTE_CODE,
151
152
  )
152
153
  print(" āœ… Base model loaded")
153
154
  except Exception as e:
@@ -169,7 +170,7 @@ except Exception as e:
169
170
 
170
171
  try:
171
172
  # Load tokenizer
172
- tokenizer = AutoTokenizer.from_pretrained(ADAPTER_MODEL, trust_remote_code=True)
173
+ tokenizer = AutoTokenizer.from_pretrained(ADAPTER_MODEL, trust_remote_code=TRUST_REMOTE_CODE)
173
174
  print(" āœ… Tokenizer loaded")
174
175
  except Exception as e:
175
176
  print(f" āŒ Failed to load tokenizer: {e}")
@@ -0,0 +1,173 @@
1
+ ---
2
+ name: infinity
3
+ description: "Enforces a strict input boundary protocol (detect, classify, filter, verify) to ensure untrusted data never reaches business logic raw."
4
+ risk: safe
5
+ source: community
6
+ date_added: "2026-06-23"
7
+ ---
8
+
9
+ # infinity — Input Boundary & Validation Protocol
10
+
11
+ ## Core Philosophy
12
+
13
+ > Nothing untrusted ever reaches the core — it is stopped before contact. No external data touches the codebase raw. Every boundary where data enters the system must have a filter.
14
+
15
+ The #1 source of silent bugs, crashes, and vulnerabilities is external data that arrives in an unexpected shape and gets used directly without checking. This skill enforces a filter layer at every entry point, every time.
16
+
17
+ ---
18
+
19
+ ## When to Use This Skill
20
+
21
+ - Use when you need to handle an API response
22
+ - Use when reading user input or adding a form handler
23
+ - Use when working with environment variables or CLI arguments
24
+ - Use when parsing webhooks or reading from the filesystem
25
+ - Use when any code calls `.body`, `.params`, `.query`, `.env`, `fs.read`, or a third-party SDK response
26
+
27
+ ---
28
+
29
+ ## The Four Phases
30
+
31
+ ### PHASE 1 — Boundary Detection
32
+
33
+ Before writing or modifying any code that involves external data, the AI must identify and list every entry point in scope:
34
+
35
+ - HTTP request bodies, headers, query params
36
+ - User form inputs and UI-submitted data
37
+ - Environment variables and config files
38
+ - Third-party API responses
39
+ - Webhook payloads
40
+ - File reads from disk
41
+ - CLI arguments
42
+ - Database query results from external sources
43
+ - WebSocket messages
44
+
45
+ > **The AI must not write any data-handling logic until every entry point in scope is listed.**
46
+
47
+ ---
48
+
49
+ ### PHASE 2 — Classify Each Input
50
+
51
+ For every entry point identified, the AI classifies it into one of three trust levels:
52
+
53
+ | Level | Definition | Examples |
54
+ |---|---|---|
55
+ | `TRUSTED` | Internal constants, hardcoded values, your own compile-time config | Enum values, hardcoded defaults, internal constants |
56
+ | `SEMI-TRUSTED` | Your own internal services, internal APIs, controlled infrastructure | Internal microservice responses, your own database reads |
57
+ | `UNTRUSTED` | Anything from users, the internet, third parties, or the filesystem | User input, external API responses, uploaded files, env vars, CLI args |
58
+
59
+ > **Rule:** `TRUSTED` inputs may be used directly. `SEMI-TRUSTED` and `UNTRUSTED` inputs must pass through a filter layer before any use.
60
+
61
+ The AI outputs this classification before writing any handling code:
62
+
63
+ ```
64
+ INFINITY — BOUNDARY MAP
65
+ ─────────────────────────────────────────
66
+ Entry Point | Trust Level | Filter Required
67
+ ─────────────────────────────────────────
68
+ req.body.email | UNTRUSTED | āœ“ format + sanitize
69
+ process.env.API_KEY | UNTRUSTED | āœ“ presence + non-empty
70
+ internalService.getData()| SEMI-TRUSTED | āœ“ schema validate
71
+ PAGINATION_LIMIT = 20 | TRUSTED | āœ— none needed
72
+ ─────────────────────────────────────────
73
+ ```
74
+
75
+ ---
76
+
77
+ ### PHASE 3 — Mandatory Filter Layer
78
+
79
+ Every `UNTRUSTED` and `SEMI-TRUSTED` input must pass through validation before it reaches any business logic, storage, or rendering. The AI must apply the right filter type for the right context:
80
+
81
+ **Type Checking**
82
+ - Verify the input is the expected type before using it
83
+ - Never assume a string is a string, a number is a number, or an array is an array
84
+
85
+ **Schema Validation**
86
+ - For objects and API responses, validate shape before accessing nested fields
87
+ - If a required field is missing, reject — do not use a fallback that hides the problem
88
+
89
+ **Sanitization**
90
+ - Strip or escape content before rendering to UI (prevent XSS)
91
+ - Normalize strings before storage (trim whitespace, consistent casing where appropriate)
92
+
93
+ **Presence & Format Checks**
94
+ - Env vars: must exist and be non-empty before use
95
+ - IDs and tokens: must match expected format before use
96
+
97
+ **Rejection Rule**
98
+ - On invalid input: reject explicitly and return a clear error
99
+ - Never silently use bad data with a fallback
100
+ - Never let bad data pass through to fix itself "downstream"
101
+
102
+ ```
103
+ // WRONG — using raw input directly
104
+ const user = await db.find(req.params.id);
105
+
106
+ // RIGHT — validate before use
107
+ const id = req.params.id;
108
+ if (!id || typeof id !== 'string' || !isValidUUID(id)) {
109
+ return res.status(400).json({ error: 'Invalid ID format' });
110
+ }
111
+ const user = await db.find(id);
112
+ ```
113
+
114
+ ---
115
+
116
+ ### PHASE 4 — Self-Check Before Done
117
+
118
+ Before the AI declares any data-handling code complete, it traces each entry point and confirms:
119
+
120
+ ```
121
+ INFINITY — VERIFICATION
122
+ ─────────────────────────────────────────
123
+ Entry Point | Filter Exists | Filter Type
124
+ ─────────────────────────────────────────
125
+ req.body.email | āœ“ YES | format + sanitize
126
+ process.env.API_KEY | āœ“ YES | presence check
127
+ internalService.getData()| āœ“ YES | schema validation
128
+ ─────────────────────────────────────────
129
+ Unfiltered inputs reaching logic: NONE āœ“
130
+ ─────────────────────────────────────────
131
+ ```
132
+
133
+ If any `UNTRUSTED` or `SEMI-TRUSTED` input reaches logic, storage, or rendering without a filter — the AI flags it. It does not silently pass.
134
+
135
+ ---
136
+
137
+ ## Hard Rules (Never Violated)
138
+
139
+ - **No raw external data in business logic.** Ever.
140
+ - **No silent fallbacks on bad input.** Reject explicitly.
141
+ - **No assuming shape.** Even if the API "always" returns a string — validate it.
142
+ - **No skipping env var checks.** Missing env vars must fail loudly at startup, not silently at runtime.
143
+ - **No partial filtering.** If you validate presence but not format, it is not filtered.
144
+ - **No filtering in the wrong place.** Filters go at the entry point — not somewhere downstream after the data has already been used once.
145
+
146
+ ---
147
+
148
+ ## What This Skill Prevents
149
+
150
+ - SQL injection via unvalidated query params
151
+ - Crashes from unexpected API response shapes
152
+ - XSS from unescaped user content rendered to UI
153
+ - Silent failures from missing env variables discovered at runtime
154
+ - Type errors from assuming external data matches expected shape
155
+ - Security vulnerabilities from untrusted data reaching sensitive operations
156
+
157
+ ---
158
+
159
+ ## Quick Reference
160
+
161
+ | Phase | Action | Writes Code? |
162
+ |---|---|---|
163
+ | 1 — Detect | List all entry points in scope | āŒ No |
164
+ | 2 — Classify | Assign trust level to each input | āŒ No |
165
+ | 3 — Filter | Write filter layer for all UNTRUSTED + SEMI-TRUSTED | āœ… Yes |
166
+ | 4 — Verify | Trace each input, confirm filter exists | āŒ No |
167
+
168
+ ---
169
+
170
+ ## Limitations
171
+
172
+ - Does not apply to purely internal logic with no external data involvement.
173
+ - May add verbosity to trivial scripts where strict validation is not required.
@@ -711,21 +711,30 @@ generate_dashboard() {
711
711
  if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
712
712
  return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
713
713
  }
714
+ function escapeHtml(value) {
715
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
716
+ '&': '&amp;',
717
+ '<': '&lt;',
718
+ '>': '&gt;',
719
+ '"': '&quot;',
720
+ "'": '&#39;'
721
+ })[char]);
722
+ }
714
723
  function renderAgent(agent) {
715
724
  const modelClass = getModelClass(agent.model);
716
- const modelName = agent.model || 'Sonnet 4.5';
717
- const agentType = agent.agent_type || 'general-purpose';
725
+ const modelName = escapeHtml(agent.model || 'Sonnet 4.5');
726
+ const agentType = escapeHtml(agent.agent_type || 'general-purpose');
718
727
  const status = agent.status === 'completed' ? 'completed' : 'active';
719
- const currentTask = agent.current_task || (agent.tasks_completed && agent.tasks_completed.length > 0
728
+ const currentTask = escapeHtml(agent.current_task || (agent.tasks_completed && agent.tasks_completed.length > 0
720
729
  ? 'Completed: ' + agent.tasks_completed.join(', ')
721
- : 'Initializing...');
730
+ : 'Initializing...'));
722
731
  const duration = formatDuration(agent.spawned_at);
723
732
  const tasksCount = agent.tasks_completed ? agent.tasks_completed.length : 0;
724
733
 
725
734
  return `
726
735
  <div class="agent-card">
727
736
  <div class="agent-header">
728
- <div class="agent-id">${agent.agent_id || 'Unknown'}</div>
737
+ <div class="agent-id">${escapeHtml(agent.agent_id || 'Unknown')}</div>
729
738
  <div class="model-badge ${modelClass}">${modelName}</div>
730
739
  </div>
731
740
  <div class="agent-type">${agentType}</div>
@@ -740,9 +749,9 @@ generate_dashboard() {
740
749
  }
741
750
  function renderTask(task) {
742
751
  const payload = task.payload || {};
743
- const title = payload.description || payload.action || task.type || 'Task';
744
- const error = task.lastError ? `<div class="error">${task.lastError}</div>` : '';
745
- return `<div class="task"><div class="id">${task.id}</div><span class="type">${task.type || 'general'}</span><div class="title">${title}</div>${error}</div>`;
752
+ const title = escapeHtml(payload.description || payload.action || task.type || 'Task');
753
+ const error = task.lastError ? `<div class="error">${escapeHtml(task.lastError)}</div>` : '';
754
+ return `<div class="task"><div class="id">${escapeHtml(task.id)}</div><span class="type">${escapeHtml(task.type || 'general')}</span><div class="title">${title}</div>${error}</div>`;
746
755
  }
747
756
  async function loadData() {
748
757
  const [pending, progress, completed, failed, agents] = await Promise.all([
@@ -8,10 +8,10 @@
8
8
  "name": "todo-app-backend",
9
9
  "version": "1.0.0",
10
10
  "dependencies": {
11
- "better-sqlite3": "^12.8.0",
12
- "cors": "^2.8.5",
11
+ "better-sqlite3": "^12.10.0",
12
+ "cors": "^2.8.6",
13
13
  "express": "^4.18.2",
14
- "express-rate-limit": "^8.5.1",
14
+ "express-rate-limit": "^8.5.2",
15
15
  "ip-address": "^10.2.0"
16
16
  },
17
17
  "devDependencies": {
@@ -303,9 +303,9 @@
303
303
  "license": "MIT"
304
304
  },
305
305
  "node_modules/better-sqlite3": {
306
- "version": "12.8.0",
307
- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
308
- "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
306
+ "version": "12.10.0",
307
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
308
+ "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
309
309
  "hasInstallScript": true,
310
310
  "license": "MIT",
311
311
  "dependencies": {
@@ -313,7 +313,7 @@
313
313
  "prebuild-install": "^7.1.1"
314
314
  },
315
315
  "engines": {
316
- "node": "20.x || 22.x || 23.x || 24.x || 25.x"
316
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
317
317
  }
318
318
  },
319
319
  "node_modules/bindings": {
@@ -459,9 +459,9 @@
459
459
  "license": "MIT"
460
460
  },
461
461
  "node_modules/cors": {
462
- "version": "2.8.5",
463
- "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
464
- "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
462
+ "version": "2.8.6",
463
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
464
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
465
465
  "license": "MIT",
466
466
  "dependencies": {
467
467
  "object-assign": "^4",
@@ -469,6 +469,10 @@
469
469
  },
470
470
  "engines": {
471
471
  "node": ">= 0.10"
472
+ },
473
+ "funding": {
474
+ "type": "opencollective",
475
+ "url": "https://opencollective.com/express"
472
476
  }
473
477
  },
474
478
  "node_modules/create-require": {
@@ -688,9 +692,9 @@
688
692
  }
689
693
  },
690
694
  "node_modules/express-rate-limit": {
691
- "version": "8.5.1",
692
- "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
693
- "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
695
+ "version": "8.5.2",
696
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
697
+ "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
694
698
  "license": "MIT",
695
699
  "dependencies": {
696
700
  "ip-address": "^10.2.0"
@@ -9,10 +9,10 @@
9
9
  "dev": "ts-node src/index.ts"
10
10
  },
11
11
  "dependencies": {
12
- "better-sqlite3": "^12.8.0",
13
- "cors": "^2.8.5",
12
+ "better-sqlite3": "^12.10.0",
13
+ "cors": "^2.8.6",
14
14
  "express": "^4.18.2",
15
- "express-rate-limit": "^8.5.1",
15
+ "express-rate-limit": "^8.5.2",
16
16
  "ip-address": "^10.2.0"
17
17
  },
18
18
  "devDependencies": {
@@ -14,8 +14,13 @@ fi
14
14
 
15
15
  echo "Creating self-signed certificate '$CERT_NAME'..."
16
16
 
17
- TEMP_CONFIG=$(mktemp)
18
- trap "rm -f $TEMP_CONFIG" EXIT
17
+ TEMP_DIR=$(mktemp -d)
18
+ chmod 700 "$TEMP_DIR"
19
+ TEMP_CONFIG="$TEMP_DIR/dev.cnf"
20
+ KEY_PATH="$TEMP_DIR/dev.key"
21
+ CRT_PATH="$TEMP_DIR/dev.crt"
22
+ P12_PATH="$TEMP_DIR/dev.p12"
23
+ trap 'rm -rf "$TEMP_DIR"' EXIT
19
24
 
20
25
  cat > "$TEMP_CONFIG" <<EOFCONF
21
26
  [ req ]
@@ -34,18 +39,16 @@ extendedKeyUsage = codeSigning
34
39
  EOFCONF
35
40
 
36
41
  openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \
37
- -nodes -keyout /tmp/dev.key -out /tmp/dev.crt \
42
+ -nodes -keyout "$KEY_PATH" -out "$CRT_PATH" \
38
43
  -config "$TEMP_CONFIG" 2>/dev/null
39
44
 
40
- openssl pkcs12 -export -out /tmp/dev.p12 \
41
- -inkey /tmp/dev.key -in /tmp/dev.crt \
45
+ openssl pkcs12 -export -out "$P12_PATH" \
46
+ -inkey "$KEY_PATH" -in "$CRT_PATH" \
42
47
  -passout pass: 2>/dev/null
43
48
 
44
- security import /tmp/dev.p12 -k ~/Library/Keychains/login.keychain-db \
49
+ security import "$P12_PATH" -k ~/Library/Keychains/login.keychain-db \
45
50
  -T /usr/bin/codesign -T /usr/bin/security
46
51
 
47
- rm -f /tmp/dev.{key,crt,p12}
48
-
49
52
  echo ""
50
53
  echo "Trust this certificate for code signing in Keychain Access."
51
54
  echo "Then export in your shell profile:"
@@ -13,8 +13,13 @@ if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:-
13
13
  exit 1
14
14
  fi
15
15
 
16
- echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/app-store-connect-key.p8
17
- trap 'rm -f /tmp/app-store-connect-key.p8 /tmp/${APP_NAME}Notarize.zip' EXIT
16
+ TEMP_DIR=$(mktemp -d)
17
+ chmod 700 "$TEMP_DIR"
18
+ KEY_PATH="$TEMP_DIR/app-store-connect-key.p8"
19
+ NOTARY_ZIP="$TEMP_DIR/${APP_NAME}Notarize.zip"
20
+ trap 'rm -rf "$TEMP_DIR"' EXIT
21
+
22
+ echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$KEY_PATH"
18
23
 
19
24
  ARCHES_VALUE=${ARCHES:-"arm64 x86_64"}
20
25
  ARCH_LIST=( ${ARCHES_VALUE} )
@@ -31,10 +36,10 @@ codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
31
36
  "$APP_BUNDLE"
32
37
 
33
38
  DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto}
34
- "$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "/tmp/${APP_NAME}Notarize.zip"
39
+ "$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$NOTARY_ZIP"
35
40
 
36
- xcrun notarytool submit "/tmp/${APP_NAME}Notarize.zip" \
37
- --key /tmp/app-store-connect-key.p8 \
41
+ xcrun notarytool submit "$NOTARY_ZIP" \
42
+ --key "$KEY_PATH" \
38
43
  --key-id "$APP_STORE_CONNECT_KEY_ID" \
39
44
  --issuer "$APP_STORE_CONNECT_ISSUER_ID" \
40
45
  --wait
@@ -16,6 +16,17 @@ import zipfile
16
16
  from pathlib import Path
17
17
 
18
18
 
19
+ def validate_input_tree(input_dir: Path):
20
+ root = input_dir.resolve(strict=True)
21
+ for path in input_dir.rglob("*"):
22
+ if path.is_symlink():
23
+ raise ValueError(f"Refusing to pack symlink: {path}")
24
+ try:
25
+ path.resolve(strict=True).relative_to(root)
26
+ except (OSError, ValueError):
27
+ raise ValueError(f"Refusing to pack path outside input directory: {path}") from None
28
+
29
+
19
30
  def main():
20
31
  parser = argparse.ArgumentParser(description="Pack a directory into an Office file")
21
32
  parser.add_argument("input_directory", help="Unpacked Office document directory")
@@ -60,6 +71,7 @@ def pack_document(input_dir, output_file, validate=False):
60
71
  raise ValueError(f"{input_dir} is not a directory")
61
72
  if output_file.suffix.lower() not in {".docx", ".pptx", ".xlsx"}:
62
73
  raise ValueError(f"{output_file} must be a .docx, .pptx, or .xlsx file")
74
+ validate_input_tree(input_dir)
63
75
 
64
76
  # Work in temporary directory to avoid modifying original
65
77
  with tempfile.TemporaryDirectory() as temp_dir:
@@ -8,6 +8,11 @@ import sys
8
8
  import zipfile
9
9
  from pathlib import Path
10
10
 
11
+ MAX_ARCHIVE_MEMBERS = 5000
12
+ MAX_MEMBER_SIZE = 100 * 1024 * 1024
13
+ MAX_TOTAL_UNCOMPRESSED = 512 * 1024 * 1024
14
+ MAX_COMPRESSION_RATIO = 1000
15
+
11
16
 
12
17
  def _is_zip_symlink(member: zipfile.ZipInfo) -> bool:
13
18
  return stat.S_ISLNK(member.external_attr >> 16)
@@ -29,19 +34,35 @@ def _extract_member(archive: zipfile.ZipFile, member: zipfile.ZipInfo, output_ro
29
34
  shutil.copyfileobj(source, target)
30
35
 
31
36
 
37
+ def _validate_archive_members(archive: zipfile.ZipFile, output_root: Path):
38
+ members = archive.infolist()
39
+ if len(members) > MAX_ARCHIVE_MEMBERS:
40
+ raise ValueError("Archive contains too many entries")
41
+
42
+ total_size = 0
43
+ for member in members:
44
+ if _is_zip_symlink(member):
45
+ raise ValueError(f"Unsafe archive entry: {member.filename}")
46
+ if not _is_safe_destination(output_root, member.filename):
47
+ raise ValueError(f"Unsafe archive entry: {member.filename}")
48
+ if member.file_size > MAX_MEMBER_SIZE:
49
+ raise ValueError(f"Archive entry too large: {member.filename}")
50
+ total_size += member.file_size
51
+ if total_size > MAX_TOTAL_UNCOMPRESSED:
52
+ raise ValueError("Archive uncompressed size is too large")
53
+ if member.compress_size and member.file_size / member.compress_size > MAX_COMPRESSION_RATIO:
54
+ raise ValueError(f"Archive entry compression ratio too high: {member.filename}")
55
+
56
+ return members
57
+
58
+
32
59
  def extract_archive_safely(input_file: str | Path, output_dir: str | Path):
33
60
  output_path = Path(output_dir)
34
61
  output_path.mkdir(parents=True, exist_ok=True)
35
62
  output_root = output_path.resolve()
36
63
 
37
64
  with zipfile.ZipFile(input_file) as archive:
38
- for member in archive.infolist():
39
- if _is_zip_symlink(member):
40
- raise ValueError(f"Unsafe archive entry: {member.filename}")
41
- if not _is_safe_destination(output_root, member.filename):
42
- raise ValueError(f"Unsafe archive entry: {member.filename}")
43
-
44
- for member in archive.infolist():
65
+ for member in _validate_archive_members(archive, output_root):
45
66
  _extract_member(archive, member, output_path)
46
67
 
47
68
 
@@ -9,6 +9,14 @@ from pathlib import Path
9
9
  import lxml.etree
10
10
 
11
11
 
12
+ def hardened_xml_parser():
13
+ return lxml.etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False, huge_tree=False)
14
+
15
+
16
+ def parse_xml(source, **kwargs):
17
+ return lxml.etree.parse(source, parser=hardened_xml_parser(), **kwargs)
18
+
19
+
12
20
  def safe_extract_all(zip_ref, destination):
13
21
  """Extract a zip archive without allowing members to escape destination."""
14
22
  destination = Path(destination).resolve()
@@ -149,7 +157,7 @@ class BaseSchemaValidator:
149
157
  for xml_file in self.xml_files:
150
158
  try:
151
159
  # Try to parse the XML file
152
- lxml.etree.parse(str(xml_file))
160
+ parse_xml(str(xml_file))
153
161
  except lxml.etree.XMLSyntaxError as e:
154
162
  errors.append(
155
163
  f" {xml_file.relative_to(self.unpacked_dir)}: "
@@ -177,7 +185,7 @@ class BaseSchemaValidator:
177
185
 
178
186
  for xml_file in self.xml_files:
179
187
  try:
180
- root = lxml.etree.parse(str(xml_file)).getroot()
188
+ root = parse_xml(str(xml_file)).getroot()
181
189
  declared = set(root.nsmap.keys()) - {None} # Exclude default namespace
182
190
 
183
191
  for attr_val in [
@@ -208,7 +216,7 @@ class BaseSchemaValidator:
208
216
 
209
217
  for xml_file in self.xml_files:
210
218
  try:
211
- root = lxml.etree.parse(str(xml_file)).getroot()
219
+ root = parse_xml(str(xml_file)).getroot()
212
220
  file_ids = {} # Track IDs that must be unique within this file
213
221
 
214
222
  # Remove all mc:AlternateContent elements from the tree
@@ -328,7 +336,7 @@ class BaseSchemaValidator:
328
336
  for rels_file in rels_files:
329
337
  try:
330
338
  # Parse relationships file
331
- rels_root = lxml.etree.parse(str(rels_file)).getroot()
339
+ rels_root = parse_xml(str(rels_file)).getroot()
332
340
 
333
341
  # Get the directory where this .rels file is located
334
342
  rels_dir = rels_file.parent
@@ -429,7 +437,7 @@ class BaseSchemaValidator:
429
437
 
430
438
  try:
431
439
  # Parse the .rels file to get valid relationship IDs and their types
432
- rels_root = lxml.etree.parse(str(rels_file)).getroot()
440
+ rels_root = parse_xml(str(rels_file)).getroot()
433
441
  rid_to_type = {}
434
442
 
435
443
  for rel in rels_root.findall(
@@ -452,7 +460,7 @@ class BaseSchemaValidator:
452
460
  rid_to_type[rid] = type_name
453
461
 
454
462
  # Parse the XML file to find all r:id references
455
- xml_root = lxml.etree.parse(str(xml_file)).getroot()
463
+ xml_root = parse_xml(str(xml_file)).getroot()
456
464
 
457
465
  # Find all elements with r:id attributes
458
466
  for elem in xml_root.iter():
@@ -549,7 +557,7 @@ class BaseSchemaValidator:
549
557
 
550
558
  try:
551
559
  # Parse and get all declared parts and extensions
552
- root = lxml.etree.parse(str(content_types_file)).getroot()
560
+ root = parse_xml(str(content_types_file)).getroot()
553
561
  declared_parts = set()
554
562
  declared_extensions = set()
555
563
 
@@ -611,7 +619,7 @@ class BaseSchemaValidator:
611
619
  continue
612
620
 
613
621
  try:
614
- root_tag = lxml.etree.parse(str(xml_file)).getroot().tag
622
+ root_tag = parse_xml(str(xml_file)).getroot().tag
615
623
  root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag
616
624
 
617
625
  if root_name in declarable_roots and path_str not in declared_parts:
@@ -850,15 +858,12 @@ class BaseSchemaValidator:
850
858
  try:
851
859
  # Load schema
852
860
  with open(schema_path, "rb") as xsd_file:
853
- parser = lxml.etree.XMLParser()
854
- xsd_doc = lxml.etree.parse(
855
- xsd_file, parser=parser, base_url=str(schema_path)
856
- )
861
+ xsd_doc = parse_xml(xsd_file, base_url=str(schema_path))
857
862
  schema = lxml.etree.XMLSchema(xsd_doc)
858
863
 
859
864
  # Load and preprocess XML
860
865
  with open(xml_file, "r") as f:
861
- xml_doc = lxml.etree.parse(f)
866
+ xml_doc = parse_xml(f)
862
867
 
863
868
  xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc)
864
869
  xml_doc = self._preprocess_for_mc_ignorable(xml_doc)
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
 
11
11
  import lxml.etree
12
12
 
13
- from .base import BaseSchemaValidator
13
+ from .base import BaseSchemaValidator, parse_xml
14
14
 
15
15
 
16
16
  def safe_extract_all(zip_ref, destination):
@@ -100,7 +100,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
100
100
  continue
101
101
 
102
102
  try:
103
- root = lxml.etree.parse(str(xml_file)).getroot()
103
+ root = parse_xml(str(xml_file)).getroot()
104
104
 
105
105
  # Find all w:t elements
106
106
  for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"):
@@ -153,7 +153,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
153
153
  continue
154
154
 
155
155
  try:
156
- root = lxml.etree.parse(str(xml_file)).getroot()
156
+ root = parse_xml(str(xml_file)).getroot()
157
157
 
158
158
  # Find all w:t elements that are descendants of w:del elements
159
159
  namespaces = {"w": self.WORD_2006_NAMESPACE}
@@ -199,7 +199,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
199
199
  continue
200
200
 
201
201
  try:
202
- root = lxml.etree.parse(str(xml_file)).getroot()
202
+ root = parse_xml(str(xml_file)).getroot()
203
203
  # Count all w:p elements
204
204
  paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
205
205
  count = len(paragraphs)
@@ -221,7 +221,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
221
221
 
222
222
  # Parse document.xml
223
223
  doc_xml_path = temp_dir + "/word/document.xml"
224
- root = lxml.etree.parse(doc_xml_path).getroot()
224
+ root = parse_xml(doc_xml_path).getroot()
225
225
 
226
226
  # Count all w:p elements
227
227
  paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
@@ -244,7 +244,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
244
244
  continue
245
245
 
246
246
  try:
247
- root = lxml.etree.parse(str(xml_file)).getroot()
247
+ root = parse_xml(str(xml_file)).getroot()
248
248
  namespaces = {"w": self.WORD_2006_NAMESPACE}
249
249
 
250
250
  # Find w:delText in w:ins that are NOT within w:del