harveyz-skill 0.6.2 → 0.7.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.
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Security tests for article-fetcher skill.
4
+ Covers all 6 findings without requiring network, browser, or Vault.
5
+ Run: python3 tests/test_security.py
6
+ """
7
+ import sys, os, unittest, subprocess, tempfile, sqlite3, ipaddress
8
+ from urllib.parse import urlparse
9
+
10
+ SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11
+ SCRIPTS_DIR = os.path.join(SKILL_DIR, 'scripts')
12
+ REFS_DIR = os.path.join(SKILL_DIR, 'references')
13
+ sys.path.insert(0, REFS_DIR)
14
+
15
+
16
+ # ── Replicate _is_safe_image_url (identical in both playwright scripts) ────────
17
+ def is_safe_image_url(src):
18
+ p = urlparse(src)
19
+ if p.scheme not in ('http', 'https'):
20
+ return False
21
+ try:
22
+ ip = ipaddress.ip_address(p.hostname)
23
+ if ip.is_private or ip.is_loopback or ip.is_link_local:
24
+ return False
25
+ except (ValueError, TypeError):
26
+ pass
27
+ return True
28
+
29
+
30
+ # ═════════════════════════════════════════════════════════════════════════════
31
+ # Finding #2 + #5 — _is_safe_image_url() scheme + IP filter
32
+ # ═════════════════════════════════════════════════════════════════════════════
33
+ class TestSafeImageUrl(unittest.TestCase):
34
+
35
+ # --- Normal URLs: must be allowed ---
36
+ def test_https_allowed(self):
37
+ self.assertTrue(is_safe_image_url('https://pbs.twimg.com/media/abc.jpg'))
38
+
39
+ def test_http_allowed(self):
40
+ self.assertTrue(is_safe_image_url('http://example.com/image.png'))
41
+
42
+ def test_cdn_with_query_allowed(self):
43
+ self.assertTrue(is_safe_image_url('https://cdn.example.com/img.jpg?w=800'))
44
+
45
+ # --- file:// — Finding #2: local file read ---
46
+ def test_file_etc_passwd_blocked(self):
47
+ self.assertFalse(is_safe_image_url('file:///etc/passwd'))
48
+
49
+ def test_file_aws_credentials_blocked(self):
50
+ self.assertFalse(is_safe_image_url('file:///Users/harvey/.aws/credentials'))
51
+
52
+ def test_file_ssh_key_blocked(self):
53
+ self.assertFalse(is_safe_image_url('file:///Users/harvey/.ssh/id_rsa'))
54
+
55
+ def test_file_claude_settings_blocked(self):
56
+ self.assertFalse(is_safe_image_url('file:///Users/harvey/.claude/settings.json'))
57
+
58
+ # --- Other dangerous schemes ---
59
+ def test_ftp_blocked(self):
60
+ self.assertFalse(is_safe_image_url('ftp://example.com/image.jpg'))
61
+
62
+ def test_data_uri_blocked(self):
63
+ self.assertFalse(is_safe_image_url('data:image/png;base64,abc123'))
64
+
65
+ def test_javascript_blocked(self):
66
+ self.assertFalse(is_safe_image_url('javascript:alert(1)'))
67
+
68
+ # --- Private IPs — Finding #5: SSRF ---
69
+ def test_private_192_blocked(self):
70
+ self.assertFalse(is_safe_image_url('http://192.168.1.1/admin'))
71
+
72
+ def test_private_10_blocked(self):
73
+ self.assertFalse(is_safe_image_url('http://10.0.0.1/secret'))
74
+
75
+ def test_private_172_blocked(self):
76
+ self.assertFalse(is_safe_image_url('http://172.16.0.1/data'))
77
+
78
+ def test_loopback_127_blocked(self):
79
+ self.assertFalse(is_safe_image_url('http://127.0.0.1:8080/api'))
80
+
81
+ def test_aws_metadata_endpoint_blocked(self):
82
+ self.assertFalse(is_safe_image_url('http://169.254.169.254/latest/meta-data/'))
83
+
84
+ def test_link_local_blocked(self):
85
+ self.assertFalse(is_safe_image_url('http://169.254.0.1/resource'))
86
+
87
+ # --- Known limitation: hostname-based internal services ---
88
+ def test_localhost_hostname_limitation(self):
89
+ # 'localhost' is a hostname, not a bare IP; ipaddress.ip_address('localhost')
90
+ # raises ValueError → NOT caught by current filter. Document this gap.
91
+ result = is_safe_image_url('http://localhost/admin')
92
+ # Currently returns True — this is a known gap.
93
+ # Mitigation: real-world pages almost never embed localhost image URLs.
94
+ self.assertTrue(result) # documents current behavior, not desired behavior
95
+
96
+
97
+ # ═════════════════════════════════════════════════════════════════════════════
98
+ # Finding #3 — URL scheme validation at playwright script entry
99
+ # ═════════════════════════════════════════════════════════════════════════════
100
+ class TestScriptUrlSchemeValidation(unittest.TestCase):
101
+
102
+ def _run(self, script, url, extra_args=None):
103
+ """Run script with given URL; return (returncode, stderr). Fast: exits before Playwright."""
104
+ cmd = ['python3', os.path.join(SCRIPTS_DIR, script), url,
105
+ '/tmp/fake_vault', '/tmp/fake_skill']
106
+ if extra_args:
107
+ cmd.insert(3, extra_args)
108
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=10)
109
+
110
+ # playwright_xcom.py
111
+ def test_xcom_rejects_file_url(self):
112
+ r = self._run('playwright_xcom.py', 'file:///etc/passwd')
113
+ self.assertEqual(r.returncode, 1)
114
+ self.assertIn('Rejected', r.stderr)
115
+
116
+ def test_xcom_rejects_javascript_url(self):
117
+ r = self._run('playwright_xcom.py', 'javascript:alert(1)')
118
+ self.assertEqual(r.returncode, 1)
119
+ self.assertIn('Rejected', r.stderr)
120
+
121
+ def test_xcom_rejects_bare_path(self):
122
+ r = self._run('playwright_xcom.py', '/etc/passwd')
123
+ self.assertEqual(r.returncode, 1)
124
+
125
+ def test_xcom_rejects_no_host(self):
126
+ r = self._run('playwright_xcom.py', 'https://')
127
+ self.assertEqual(r.returncode, 1)
128
+
129
+ # playwright_web.py (takes html_path as argv[2])
130
+ def test_web_rejects_file_url(self):
131
+ cmd = ['python3', os.path.join(SCRIPTS_DIR, 'playwright_web.py'),
132
+ 'file:///etc/passwd', '/tmp/fake.html', '/tmp/fake_vault', '/tmp/fake_skill']
133
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
134
+ self.assertEqual(r.returncode, 1)
135
+ self.assertIn('Rejected', r.stderr)
136
+
137
+ def test_web_rejects_ftp_url(self):
138
+ cmd = ['python3', os.path.join(SCRIPTS_DIR, 'playwright_web.py'),
139
+ 'ftp://example.com/page', '/tmp/fake.html', '/tmp/fake_vault', '/tmp/fake_skill']
140
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
141
+ self.assertEqual(r.returncode, 1)
142
+
143
+
144
+ # ═════════════════════════════════════════════════════════════════════════════
145
+ # Finding #6 — validate_article.py: env var params, no shell injection surface
146
+ # ═════════════════════════════════════════════════════════════════════════════
147
+ class TestValidateArticle(unittest.TestCase):
148
+
149
+ def setUp(self):
150
+ self.tmpdir = tempfile.mkdtemp()
151
+ self.db_path = os.path.join(self.tmpdir, 'url-index.db')
152
+ conn = sqlite3.connect(self.db_path)
153
+ conn.execute('''CREATE TABLE url_index (
154
+ url TEXT PRIMARY KEY, title TEXT, fetched_at TEXT,
155
+ issues TEXT, category TEXT, origin_path TEXT, article_path TEXT
156
+ )''')
157
+ conn.commit()
158
+ conn.close()
159
+
160
+ def _make_md(self, filename):
161
+ path = os.path.join(self.tmpdir, filename)
162
+ with open(path, 'w') as f:
163
+ f.write("""---
164
+ publish_date: 2026-01-01
165
+ fetch_date: 2026-05-17
166
+ author: Test Author
167
+ source_url: https://example.com/test
168
+ origin_title: "Test Article"
169
+ description: A test article for security testing
170
+ tags:
171
+ - test
172
+ ---
173
+
174
+ # Test Article
175
+
176
+ Content here.
177
+ """)
178
+ return path
179
+
180
+ def _run(self, url='https://example.com/test', category='',
181
+ origin_path=None, article_path=None):
182
+ env = {
183
+ 'ARTICLE_URL': url,
184
+ 'ARTICLE_ORIGIN': origin_path or self.origin,
185
+ 'ARTICLE_PATH': article_path or self.article,
186
+ 'ARTICLE_DB': self.db_path,
187
+ 'ARTICLE_SKILL_DIR': SKILL_DIR,
188
+ 'ARTICLE_CATEGORY': category,
189
+ 'PATH': os.environ.get('PATH', ''),
190
+ }
191
+ return subprocess.run(
192
+ ['python3', os.path.join(SCRIPTS_DIR, 'validate_article.py')],
193
+ env=env, capture_output=True, text=True, timeout=30
194
+ )
195
+
196
+ def test_valid_article_exits_zero(self):
197
+ self.origin = self._make_md('origin.md')
198
+ self.article = self._make_md('article.md')
199
+ r = self._run()
200
+ self.assertEqual(r.returncode, 0)
201
+ self.assertIn('翻译完成', r.stdout)
202
+
203
+ def test_writes_url_to_sqlite(self):
204
+ self.origin = self._make_md('origin2.md')
205
+ self.article = self._make_md('article2.md')
206
+ url = 'https://example.com/article2'
207
+ self._run(url=url)
208
+ conn = sqlite3.connect(self.db_path)
209
+ row = conn.execute('SELECT url FROM url_index WHERE url=?', (url,)).fetchone()
210
+ conn.close()
211
+ self.assertIsNotNone(row)
212
+
213
+ def test_category_stored_in_sqlite(self):
214
+ self.origin = self._make_md('origin3.md')
215
+ self.article = self._make_md('article3.md')
216
+ url = 'https://example.com/article3'
217
+ self._run(url=url, category='AI')
218
+ conn = sqlite3.connect(self.db_path)
219
+ row = conn.execute('SELECT category FROM url_index WHERE url=?', (url,)).fetchone()
220
+ conn.close()
221
+ self.assertEqual(row[0], 'AI')
222
+
223
+ def test_missing_article_exits_nonzero(self):
224
+ self.origin = self._make_md('origin4.md')
225
+ self.article = '/nonexistent/path/article.md'
226
+ r = self._run()
227
+ self.assertEqual(r.returncode, 1)
228
+
229
+ def test_url_with_shell_metacharacters_stored_literally(self):
230
+ """Key test: env var passing means shell metacharacters in URL are safe."""
231
+ self.origin = self._make_md('origin5.md')
232
+ self.article = self._make_md('article5.md')
233
+ nasty_url = "https://example.com/it's-a-test;rm${IFS}-rf${IFS}/"
234
+ r = self._run(url=nasty_url)
235
+ self.assertEqual(r.returncode, 0)
236
+ conn = sqlite3.connect(self.db_path)
237
+ row = conn.execute('SELECT url FROM url_index WHERE url=?', (nasty_url,)).fetchone()
238
+ conn.close()
239
+ # URL stored as literal string, shell never saw it
240
+ self.assertIsNotNone(row)
241
+
242
+ def test_category_with_quotes_stored_literally(self):
243
+ """Category with quotes would break python3 -c; env var is safe."""
244
+ self.origin = self._make_md('origin6.md')
245
+ self.article = self._make_md('article6.md')
246
+ url = 'https://example.com/article6'
247
+ nasty_cat = "AI'; import os; os.system('id')"
248
+ r = self._run(url=url, category=nasty_cat)
249
+ self.assertEqual(r.returncode, 0)
250
+ conn = sqlite3.connect(self.db_path)
251
+ row = conn.execute('SELECT category FROM url_index WHERE url=?', (url,)).fetchone()
252
+ conn.close()
253
+ self.assertEqual(row[0], nasty_cat) # stored verbatim
254
+
255
+
256
+ if __name__ == '__main__':
257
+ unittest.main(verbosity=2)
package/skills-index.json CHANGED
@@ -6,17 +6,18 @@
6
6
  { "name": "p-launch", "bundle": "shell-tools" }
7
7
  ],
8
8
  "bundleMeta": {
9
- "analysis": "分析工具(skill-analyzer)",
9
+ "analysis": "分析工具(skill-analyzer + git-cleanup)",
10
10
  "brainstorming": "设计与规划工具(brainstorming + writing-plans)",
11
11
  "dev": "开发工作流(executing-plans + systematic-debugging + using-git-worktrees + git-workflow-init)",
12
12
  "document": "文档工具(diataxis-docs)",
13
13
  "harness": "测试工具(full-stack-debug-env)",
14
14
  "task": "任务管理(pm-task-dispatch + task-close)",
15
15
  "web-fetch": "网页抓取工具(article-fetcher)",
16
- "writing": "写作工具(mermaid-diagram)"
16
+ "writing": "写作工具(mermaid-diagram)",
17
+ "design": "设计同步工具(sync-design-html)"
17
18
  },
18
19
  "skills": [
19
- { "path": "analysis/skill-analyzer", "bundle": "analysis", "exclude": ["research/"] },
20
+ { "path": "analysis/skill-analyzer", "bundle": "analysis" },
20
21
  { "path": "harness/diataxis-docs", "bundle": "document" },
21
22
  { "path": "harness/full-stack-debug-env", "bundle": "harness" },
22
23
  { "path": "superpowers-fork/brainstorming", "bundle": "brainstorming" },
@@ -28,6 +29,19 @@
28
29
  { "path": "task/pm-task-dispatch", "bundle": "task" },
29
30
  { "path": "task/task-close", "bundle": "task" },
30
31
  { "path": "web-fetch/article-fetcher", "bundle": "web-fetch" },
31
- { "path": "writing/mermaid-diagram", "bundle": "writing" }
32
+ { "path": "writing/mermaid-diagram", "bundle": "writing" },
33
+ { "path": "design/sync-design-html", "bundle": "design" },
34
+ { "path": "analysis/git-cleanup", "bundle": "analysis" }
35
+ ],
36
+ "hooks": [
37
+ {
38
+ "name": "check-similar-branch",
39
+ "description": "用 LLM 语义分析检测相似分支",
40
+ "path": "hooks/check-similar-branch",
41
+ "event": "PreToolUse",
42
+ "matcher": "Bash",
43
+ "timeout": 60,
44
+ "statusMessage": "检查相似分支..."
45
+ }
32
46
  ]
33
47
  }