scanners/lib/git-clone.mjs accepted only github.com, so the README's
Forgejo example (and /security scan on any Forgejo, Codeberg or self-hosted
URL) failed validation; scan.md and plugin-audit.md also routed only
https://github.com/ to the clone path.
Chose generic HTTPS over adding one host to an allowlist because there is
no documented security reason for the host check. Read before choosing:
git log --follow (import + E12 only), the file's comments, CHANGELOG v2.4.0
("GitHub repo URL support", feature framing), security-hardening-guide §2
and §7 (the sandbox is the defence against filter/smudge drivers), and
review-2026-06-20, which lists "vsix-fetch (HTTPS host allowlist)" as a
protection but no host check for git-clone. What protects a clone is the
OS sandbox, GIT_SANDBOX_CONFIG and GIT_SANDBOX_ENV, for every host alike.
Measured: with GIT_SANDBOX_ENV, git reads 0 config lines outside a repo;
without it, osxkeychain comes from Xcode's system gitconfig. So no stored
credential is offered to an unknown host.
The shape stays strict: https only, no userinfo, host starts and ends
alphanumeric (no leading -), optional port, exactly owner/repo, no query
or fragment. SSH stays GitHub-only: ssh uses the user's own keys and
~/.ssh/config, which the git environment does not isolate.
Tests (red first): Forgejo and any-host accept 2 failing -> green; guards
for http, userinfo, leading -, extra path, query, ext:: and file:: and
non-GitHub SSH hold. The old "rejects non-GitHub URL" case still passes
(it has no owner/repo) and is renamed to say so. Regex timed linear
(< 1 ms at 200k chars). Live: a sandboxed clone of
https://git.fromaitochitta.com/open/llm-security.git exits 0 at 5208420,
cleaned up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.7 KiB
| name | description | allowed-tools | model |
|---|---|---|---|
| security:scan | Scan files, directories, or GitHub repos for security issues — secrets, injection vulnerabilities, supply chain risks, OWASP LLM patterns | Read, Glob, Grep, Bash, Agent | sonnet |
/security scan [path|url]
Scan target for security issues. Accepts local paths or remote git URLs (HTTPS on any host, SSH on GitHub). Delegates to specialized agents sequentially.
Step 1: Resolve Target
- If
$ARGUMENTScontains--deep→ strip it, setrun_deep_scan = true - If
$ARGUMENTScontains--branch <name>→ strip it, setbranch = <name> - If
$ARGUMENTSis empty →target = ".",clone_path = null - If
$ARGUMENTSstarts withhttps://orgit@github.com:→ Run:node <plugin-root>/scanners/lib/git-clone.mjs clone "<url>" [--branch <branch>]If exit code != 0 → show error to user and STOP Setclone_path= stdout (trimmed),target = clone_pathSetremote_url = <url>for display - Otherwise →
target = $ARGUMENTS,clone_path = null
IMPORTANT: Cleanup Guarantee (remote scans)
If clone_path != null, the following cleanup MUST run regardless of scan outcome.
If ANY step between clone and cleanup fails or errors, STILL run cleanup before stopping:
node <plugin-root>/scanners/lib/git-clone.mjs cleanup "<clone_path>"node <plugin-root>/scanners/lib/fs-utils.mjs cleanup "<evidence_file>"(ifevidence_fileis set)
Step 1.5: Pre-extraction (remote scans only)
If clone_path != null (target is a cloned remote repo):
Get temp path: node <plugin-root>/scanners/lib/fs-utils.mjs tmppath "content-extract.json"
Run: node <plugin-root>/scanners/content-extractor.mjs "<target>" --output-file "<evidence_file>"
If exit code != 0 → warn user, set evidence_file = null (fall back to direct scan)
Otherwise set evidence_file = the temp path. Print the compact summary line to user.
Step 2: Detect Scan Type
Single .md file: run_skill_scan = true, run_mcp_scan = false
Directory: Glob for **/commands/*.md, **/agents/*.md, **/skills/*/SKILL.md → run_skill_scan = true. Glob for **/.mcp.json, **/package.json, **/.claude/settings.json with mcpServers → run_mcp_scan = true. Neither → skill scan only.
Record ISO 8601 timestamp.
Step 3: Plugin Root
This file is at <plugin-root>/commands/scan.md. Use absolute paths for knowledge files.
Step 3.5: Registry Check (local scans only)
If clone_path == null (local scan) and run_skill_scan == true:
node -e "
import { fingerprintSkill, checkRegistry } from '<plugin-root>/scanners/lib/skill-registry.mjs';
const r = fingerprintSkill('<target>');
const c = checkRegistry(r.fingerprint, '<plugin-root>');
console.log(JSON.stringify({ fingerprint: r.fingerprint, name: r.name, files: r.files, ...c }));
" --input-type=module
If found == true and stale == false: display cached result and set skip_skill_scan = true:
**Registry hit:** <name> (fingerprint: <first 12 chars>)
Verdict: <verdict> | Risk: <score>/100 | Last scanned: <date> | Scans: <count>
(Use `/security registry scan <target>` to force re-scan)
Otherwise set skip_skill_scan = false and store registry_fingerprint and registry_name for post-scan registration.
Step 4: Spawn Agents Sequentially
Use registered subagent types — they contain full scan procedures as system prompt.
Skill Scanner (if run_skill_scan = true AND skip_skill_scan != true): subagent_type: "llm-security:skill-scanner-agent", model: "sonnet":
If evidence_file is set (remote scan — evidence-package mode):
EVIDENCE-PACKAGE MODE. Read the pre-extracted evidence at: <evidence_file> Read knowledge: <plugin-root>/knowledge/skill-threat-patterns.md, <plugin-root>/knowledge/secrets-patterns.md Analyze the JSON sections: injection_findings, frontmatter_inventory, shell_commands, credential_references, persistence_signals, claude_md_analysis, cross_instruction_flags. DO NOT use Read/Glob/Grep on the target directory — all evidence is in the package.
[INJECTION-PATTERN-STRIPPED]markers are confirmed findings — report them. Return findings with severity, category, file, line, OWASP ref, evidence, remediation. End with JSON:{"scanner":"skill-scanner","verdict":"ALLOW|WARNING|BLOCK","risk_score":N,"counts":{"critical":0,"high":0,"medium":0,"low":0,"info":0},"files_scanned":N}
Otherwise (local scan — direct mode):
Scan target: <target> Read: <plugin-root>/knowledge/skill-threat-patterns.md, <plugin-root>/knowledge/secrets-patterns.md Return findings with severity, category, file, line, OWASP ref, evidence, remediation. End with JSON:
{"scanner":"skill-scanner","verdict":"ALLOW|WARNING|BLOCK","risk_score":N,"counts":{"critical":0,"high":0,"medium":0,"low":0,"info":0},"files_scanned":N}
MCP Scanner (if run_mcp_scan = true, run AFTER skill scanner): subagent_type: "llm-security:mcp-scanner-agent", model: "sonnet":
If evidence_file is set (remote scan — evidence-package mode):
EVIDENCE-PACKAGE MODE. Read the pre-extracted evidence at: <evidence_file> Read: <plugin-root>/knowledge/mcp-threat-patterns.md Analyze: mcp_tool_descriptions (check hidden instructions, length >500, injection_detected), shell_commands, credential_references. DO NOT use Read/Glob/Grep on the target directory. Return findings with severity, category, evidence, remediation. End with JSON:
{"scanner":"mcp-scanner","verdict":"ALLOW|WARNING|BLOCK","risk_score":N,"counts":{"critical":0,"high":0,"medium":0,"low":0,"info":0},"files_scanned":N}
Otherwise (local scan — direct mode):
Scan target: <target> Read: <plugin-root>/knowledge/mcp-threat-patterns.md Return findings with severity, category, server name, evidence, remediation. End with JSON:
{"scanner":"mcp-scanner","verdict":"ALLOW|WARNING|BLOCK","risk_score":N,"counts":{"critical":0,"high":0,"medium":0,"low":0,"info":0},"files_scanned":N}
Step 5: Aggregate and Report
Combine counts. risk_score = riskScore(counts) (severity-dominated v2 model — see scanners/lib/severity.mjs).
Verdict: critical ≥ 1 OR score ≥ 65 → BLOCK; high ≥ 1 OR score ≥ 15 → WARNING; else ALLOW.
Output banner then all findings grouped by severity (critical→info). Each finding:
### [SEV] Title with Category, File:line, OWASP, Evidence, Remediation.
For TFA (Toxic Flow Analysis) findings, render the chain description prominently:
- Show the 3 trifecta legs (Input, Access, Exfil) with their evidence
- Note mitigation status (which hooks are active)
- Group direct trifectas separately from cross-component trifectas
Step 5.5: Register in Skill Registry (local scans only)
If clone_path == null and skip_skill_scan != true and registry_fingerprint is set:
node -e "
import { registerScan } from '<plugin-root>/scanners/lib/skill-registry.mjs';
registerScan({
skillPath: '<target>',
fingerprint: '<registry_fingerprint>',
name: '<registry_name>',
files: <registry_files_json>,
verdict: '<computed_verdict>',
risk_score: <computed_risk_score>,
counts: <computed_counts_json>,
files_scanned: <files_scanned>
}, '<plugin-root>');
" --input-type=module
Step 6: Deep Scan (only if --deep)
If run_deep_scan = true, run /security deep-scan <target> logic:
Get temp path, run node <plugin-root>/scanners/scan-orchestrator.mjs "<target>" --output-file "<tmp>".
Parse stdout aggregate JSON. Merge with LLM findings. Re-evaluate verdict. Output "Deep Scan Findings" section with CRITICAL/HIGH only.
Step 7: Cleanup (only if remote)
If clone_path != null:
Run: node <plugin-root>/scanners/lib/git-clone.mjs cleanup "<clone_path>"
If cleanup fails → warn: "Could not remove temp dir <clone_path> — remove manually."
If evidence_file != null:
Run: node <plugin-root>/scanners/lib/fs-utils.mjs cleanup "<evidence_file>"
Step 8: HTML Report
After producing the markdown report (Step 5) and any cleanup (Step 7):
-
Compute a temp markdown path:
node -p "require('path').join(require('os').tmpdir(), 'sec-scan-' + Date.now() + '.md')" -
Use the Write tool to save the entire markdown report you just produced (banner + all findings sections + any Deep Scan section) to that temp path. Do not summarize — write it verbatim.
-
Run the renderer:
node <plugin-root>/scripts/render-report.mjs scan --in "<temp-md-path>"The CLI writes
reports/scan-<YYYYMMDD-HHmmss>.htmlrelative to CWD and printsfile:///abs/path.htmlon stdout (one line). -
Append this to your response (markdown link, no bare URL):
HTML report: Open in browser
If the CLI exits non-zero, mention the error but do not block — the markdown report above is the primary deliverable.