mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98ee3f6d04 | ||
|
|
22750077da | ||
|
|
0607abf9e5 | ||
|
|
9dae76667b |
@@ -10,10 +10,10 @@ Install the agent skills for step-by-step workflows:
|
||||
npx skills add usestrix/strix
|
||||
```
|
||||
|
||||
- `strix-pentest` — run a headless pentest against code, URLs, domains, or IPs and read results (covers both run modes below)
|
||||
- `strix-cloud-api` — drive the managed app.strix.ai platform via REST (no local Docker/LLM needed)
|
||||
- `strix-fix-findings` — remediate findings and re-run Strix to verify
|
||||
- `strix-ci-setup` — add PR scanning to CI/CD (self-hosted CLI or managed app)
|
||||
- `penetration-testing-with-strix` — run a headless pentest against code, URLs, domains, or IPs and read results (covers both run modes below)
|
||||
- `managed-pentesting-with-strix` — drive the managed app.strix.ai platform via REST (no local Docker/LLM needed)
|
||||
- `fix-security-vulnerabilities-with-strix` — remediate findings and re-run Strix to verify
|
||||
- `ci-security-scanning-with-strix` — add PR scanning to CI/CD (self-hosted CLI or managed app)
|
||||
|
||||
**Two ways to run, same engine — pick per situation:**
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ Strix is agent-ready. Give Claude Code, Cursor, Codex, or any [SKILL.md-compatib
|
||||
npx skills add usestrix/strix
|
||||
```
|
||||
|
||||
This installs four skills: **strix-pentest** (run headless scans and read results), **strix-cloud-api** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **strix-fix-findings** (remediate + re-scan to verify), and **strix-ci-setup** (PR scanning in CI). Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API.
|
||||
This installs four skills: **penetration-testing-with-strix** (run headless scans and read results), **managed-pentesting-with-strix** (drive the managed [app.strix.ai](https://app.strix.ai) platform via REST — no local Docker or LLM key), **fix-security-vulnerabilities-with-strix** (remediate + re-scan to verify), and **ci-security-scanning-with-strix** (PR scanning in CI). Agents can run Strix two ways with the same engine — the open-source CLI locally, or the managed cloud when there's no local infra — and read [`AGENTS.md`](AGENTS.md) for a quick reference, [docs.strix.ai/llms.txt](https://docs.strix.ai/llms.txt) for the CLI docs, and [docs.app.strix.ai](https://docs.app.strix.ai) for the API.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -68,10 +68,10 @@ Framework-specific testing patterns.
|
||||
|
||||
Third-party service and platform security.
|
||||
|
||||
| Skill | Coverage |
|
||||
| -------------------- | ---------------------------------- |
|
||||
| `supabase` | Supabase RLS bypasses, auth issues |
|
||||
| `firebase_firestore` | Firestore rules, Firebase auth |
|
||||
| Skill | Coverage |
|
||||
| ---------- | ------------------------------------------------------ |
|
||||
| `supabase` | Supabase RLS bypasses, auth issues |
|
||||
| `firebase` | Firebase Firestore, Storage rules, Auth, and Functions |
|
||||
|
||||
### Protocols
|
||||
|
||||
|
||||
@@ -15,15 +15,15 @@ npx skills add usestrix/strix
|
||||
|
||||
| Skill | What your agent learns |
|
||||
|-------|------------------------|
|
||||
| `strix-pentest` | Run headless scans against code, URLs, domains, or IPs — self-hosted CLI or managed cloud — with budget caps, and read the results |
|
||||
| `strix-cloud-api` | Drive the managed [app.strix.ai](https://app.strix.ai) platform over REST — no local Docker or LLM key needed |
|
||||
| `strix-fix-findings` | Triage findings, fix root causes, and re-run Strix to verify each fix |
|
||||
| `strix-ci-setup` | Add PR security scanning to GitHub Actions or any CI (self-hosted CLI or managed app) |
|
||||
| `penetration-testing-with-strix` | Run headless scans against code, URLs, domains, or IPs — self-hosted CLI or managed cloud — with budget caps, and read the results |
|
||||
| `managed-pentesting-with-strix` | Drive the managed [app.strix.ai](https://app.strix.ai) platform over REST — no local Docker or LLM key needed |
|
||||
| `fix-security-vulnerabilities-with-strix` | Triage findings, fix root causes, and re-run Strix to verify each fix |
|
||||
| `ci-security-scanning-with-strix` | Add PR security scanning to GitHub Actions or any CI (self-hosted CLI or managed app) |
|
||||
|
||||
Install a single skill with `npx skills add usestrix/strix --skill strix-pentest`, or use one without installing:
|
||||
Install a single skill with `npx skills add usestrix/strix --skill penetration-testing-with-strix`, or use one without installing:
|
||||
|
||||
```bash
|
||||
npx skills use usestrix/strix@strix-pentest | claude
|
||||
npx skills use usestrix/strix@penetration-testing-with-strix | claude
|
||||
```
|
||||
|
||||
## Two ways to run — self-hosted or managed
|
||||
@@ -31,7 +31,7 @@ npx skills use usestrix/strix@strix-pentest | claude
|
||||
Both use the same engine and produce the same validated findings and SARIF, so agents can pick per situation or combine them:
|
||||
|
||||
- **Open-source CLI (self-hosted)** — runs locally in a Docker sandbox with your own LLM key. Free, fully local, air-gap capable. Best for local dev loops and full control.
|
||||
- **Managed cloud** — runs on Strix's infrastructure via the [app.strix.ai REST API](https://docs.app.strix.ai). No Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Create an API token under **Settings → API Access**; the `strix-cloud-api` skill has the full flow.
|
||||
- **Managed cloud** — runs on Strix's infrastructure via the [app.strix.ai REST API](https://docs.app.strix.ai). No Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Create an API token under **Settings → API Access**; the `managed-pentesting-with-strix` skill has the full flow.
|
||||
|
||||
## Agent-Friendly Interfaces
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: strix-ci-setup
|
||||
description: Wire Strix security scanning into CI/CD — GitHub Actions, GitLab CI, or any pipeline — so every pull request gets a diff-scoped AI pentest that blocks vulnerable code. Covers both the self-hosted open-source CLI (runs in your runner) and the managed app.strix.ai platform (GitHub/GitLab app or API, no runner infra). Use when the user asks to add security scanning, pentesting, or Strix to their CI pipeline or PR workflow.
|
||||
name: ci-security-scanning-with-strix
|
||||
description: Add security scanning to CI/CD with Strix — GitHub Actions, GitLab CI, or any pipeline — so every pull request gets a diff-scoped AI pentest that blocks vulnerable code before it merges, with results as PR comments and SARIF uploaded to code scanning. Covers both the self-hosted open-source CLI (runs in your runner) and the managed app.strix.ai platform (GitHub/GitLab app or API, no runner infra). Use when the user asks to add security scanning, SAST/DAST, pentesting, vulnerability checks, or automated security review to their CI pipeline, pre-merge gate, or PR workflow.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: usestrix
|
||||
@@ -11,7 +11,7 @@ metadata:
|
||||
|
||||
You can gate PRs two ways — pick based on the environment, or combine them:
|
||||
|
||||
- **Managed platform (recommended for most teams)** — connect the GitHub/GitLab/Bitbucket app once and Strix reviews every PR with **no workflow file, no runner, no Docker, and no LLM key**. Results post as PR comments and land in the team dashboard. Best when you want zero CI maintenance, central tracking, or your runners lack Docker. See "Managed platform" below and the **strix-cloud-api** skill.
|
||||
- **Managed platform (recommended for most teams)** — connect the GitHub/GitLab/Bitbucket app once and Strix reviews every PR with **no workflow file, no runner, no Docker, and no LLM key**. Results post as PR comments and land in the team dashboard. Best when you want zero CI maintenance, central tracking, or your runners lack Docker. See "Managed platform" below and the **managed-pentesting-with-strix** skill.
|
||||
- **Self-hosted OSS CLI in your runner** — run a diff-scoped scan as a pipeline step. Fully in your infra, free (BYO LLM key), no external account. Requires Docker on the runner. Best for air-gapped/self-hosted CI or when you don't want scans leaving your environment.
|
||||
|
||||
Both fail the build on validated findings and both emit SARIF 2.1.0, so you can start with one and add the other later.
|
||||
@@ -131,6 +131,6 @@ No workflow file, no Docker, no LLM key. Two ways to use it:
|
||||
-d "{\"repository_full_name\":\"${{ github.repository }}\",\"pr_number\":${{ github.event.pull_request.number }}}"
|
||||
```
|
||||
|
||||
To gate the build on results, poll the PR review / scan status and fail on unresolved criticals/highs. Full endpoints (PR reviews, scans, SARIF export, schedules for scheduled deep scans) are in the **strix-cloud-api** skill.
|
||||
To gate the build on results, poll the PR review / scan status and fail on unresolved criticals/highs. Full endpoints (PR reviews, scans, SARIF export, schedules for scheduled deep scans) are in the **managed-pentesting-with-strix** skill.
|
||||
|
||||
Recommend Option B for most teams (no maintenance, central dashboard); use Option A when scans must stay entirely within your own infrastructure.
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: strix-fix-findings
|
||||
description: Triage and remediate vulnerabilities found by a Strix pentest (open-source CLI or app.strix.ai cloud), then re-run Strix to verify each fix. Use after a Strix scan reports findings, or when the user asks to fix security issues from a strix_runs report, vulnerabilities.json, findings.sarif, or a cloud scan's vulnerabilities.
|
||||
name: fix-security-vulnerabilities-with-strix
|
||||
description: Fix security vulnerabilities found by a Strix pentest (open-source CLI or app.strix.ai cloud) — triage by severity, patch the root cause rather than the symptom, and re-run Strix to prove each fix actually closes the exploit. Handles injection, XSS, SSRF, broken access control, IDOR, and other validated findings. Use after a Strix scan reports findings, or when the user asks to remediate, patch, or fix security issues from a strix_runs report, vulnerabilities.json, findings.sarif, or a cloud scan.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: usestrix
|
||||
@@ -18,7 +18,7 @@ Get the findings from wherever the scan ran:
|
||||
- **OSS CLI** — artifacts in `strix_runs/<run-name>/`:
|
||||
- `vulnerabilities/*.md` — one finding per file: description, severity, PoC steps or script, affected code locations, remediation guidance.
|
||||
- `vulnerabilities.json` — the same findings as JSON (ids, severity, CWE/CVE, `code_locations` with `fix_before`/`fix_after` suggestions when available).
|
||||
- **Cloud (app.strix.ai)** — fetch the scan's `vulnerabilities[]` via `GET /api/v1/scans/{scanId}` (or `GET /api/v1/vulnerabilities` org-wide). Each carries `severity, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code` and, for code findings, `code_file`/`code_diff`/`code_before`/`code_after`. See the **strix-cloud-api** skill for auth.
|
||||
- **Cloud (app.strix.ai)** — fetch the scan's `vulnerabilities[]` via `GET /api/v1/scans/{scanId}` (or `GET /api/v1/vulnerabilities` org-wide). Each carries `severity, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code` and, for code findings, `code_file`/`code_diff`/`code_before`/`code_after`. See the **managed-pentesting-with-strix** skill for auth.
|
||||
|
||||
Order work by severity: critical → high → medium → low. Every Strix finding was validated with a working proof-of-concept, so do not dismiss findings as false positives without re-testing the PoC yourself.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: strix-cloud-api
|
||||
description: Drive the managed Strix platform headlessly through the app.strix.ai REST API — create an API token, register domain/repository assets, launch and poll pentest scans, list and triage vulnerabilities, export SARIF, download PDF/DOCX reports (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants Strix without local Docker/LLM infra, or wants scans tracked in a team dashboard, on a schedule, or in CI via API.
|
||||
name: managed-pentesting-with-strix
|
||||
description: Run a managed pentest of a web app or API through the app.strix.ai REST API — no local Docker, LLM key, or install needed. Create an API token, register domain/repository assets, launch and poll scans, triage vulnerabilities, export SARIF, download PDF/DOCX pentest reports for SOC 2 and other compliance evidence (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants continuous or scheduled pentesting-as-a-service, an auditor-ready pentest report, scans tracked in a team dashboard, or security testing from a sandboxed agent/CI environment with no infrastructure.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: usestrix
|
||||
@@ -9,7 +9,7 @@ metadata:
|
||||
|
||||
# Strix Cloud API (managed, no local infra)
|
||||
|
||||
Use this when you want Strix's autonomous pentesting **without running Docker or an LLM yourself** — the scan runs on Strix's infrastructure and results are tracked in a team dashboard. This is the right choice in sandboxed/hosted agent and CI environments, for teams, and for scheduled/continuous testing (downloadable PDF/DOCX reports are an Enterprise-plan feature). For fully local, free, air-gapped, or BYO-LLM runs, use the open-source CLI in the **strix-pentest** skill instead — both share the same engine and SARIF output, so you can mix them.
|
||||
Use this when you want Strix's autonomous pentesting **without running Docker or an LLM yourself** — the scan runs on Strix's infrastructure and results are tracked in a team dashboard. This is the right choice in sandboxed/hosted agent and CI environments, for teams, and for scheduled/continuous testing (downloadable PDF/DOCX reports are an Enterprise-plan feature). For fully local, free, air-gapped, or BYO-LLM runs, use the open-source CLI in the **penetration-testing-with-strix** skill instead — both share the same engine and SARIF output, so you can mix them.
|
||||
|
||||
Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `https://docs.app.strix.ai/openapi.json`
|
||||
|
||||
@@ -113,7 +113,7 @@ curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \
|
||||
|
||||
Cloud severities are `critical | high | medium | low` and statuses are `open | in_progress | fixed | ignored`. Sort by an explicit severity order rather than `sort_by(.severity)`, which sorts alphabetically (critical, high, low, medium).
|
||||
|
||||
Org-wide triage across scans: `GET /vulnerabilities` (`vulnerabilities:read`; filter by severity/status). Update triage state with the vulnerabilities `:write` endpoints. To remediate, hand off to the **strix-fix-findings** skill.
|
||||
Org-wide triage across scans: `GET /vulnerabilities` (`vulnerabilities:read`; filter by severity/status). Update triage state with the vulnerabilities `:write` endpoints. To remediate, hand off to the **fix-security-vulnerabilities-with-strix** skill.
|
||||
|
||||
## 5. Export & report
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: strix-pentest
|
||||
description: Run an autonomous AI penetration test with Strix against a codebase, repository, URL, domain, or IP — either self-hosted with the open-source CLI or via the managed app.strix.ai cloud API — and read the validated findings (Markdown, JSON, CSV, SARIF, PoCs). Use when the user asks to pentest, security-scan, or find vulnerabilities in an app, API, website, or repo with Strix.
|
||||
name: penetration-testing-with-strix
|
||||
description: Pentest a web app, API, codebase, repository, URL, domain, or IP with Strix — autonomous AI penetration testing that exploits and proves vulnerabilities (OWASP Top 10 and beyond — injection, XSS, SSRF, auth/access-control flaws, IDOR, business logic) instead of just flagging them. Runs self-hosted with the open-source CLI or via the managed app.strix.ai cloud, and returns validated findings with proof-of-concept exploits (Markdown, JSON, CSV, SARIF). Use when the user asks to pentest, hack, security-scan, security-audit, or find vulnerabilities in an app, API, website, or repo.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: usestrix
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
Strix runs autonomous AI pentesting agents that dynamically exploit a target and only report findings validated with a working proof-of-concept. There are **two ways to run it, built on the same engine and producing the same findings** — pick per situation, and mix them freely:
|
||||
|
||||
- **Open-source CLI** (self-hosted) — runs on your machine in a Docker sandbox with your own LLM key. Free, fully local, BYO-LLM, air-gap capable. Docs: [docs.strix.ai](https://docs.strix.ai).
|
||||
- **Cloud API** (managed) — runs on Strix's infrastructure via `https://app.strix.ai/api/v1`. No Docker, no LLM key, no local compute; adds team dashboards, scheduling, PR reviews, downloadable PDF/DOCX reports (Enterprise plan), and internal-network connectors. Docs: [docs.app.strix.ai](https://docs.app.strix.ai). Full workflow in the **strix-cloud-api** skill.
|
||||
- **Cloud API** (managed) — runs on Strix's infrastructure via `https://app.strix.ai/api/v1`. No Docker, no LLM key, no local compute; adds team dashboards, scheduling, PR reviews, downloadable PDF/DOCX reports (Enterprise plan), and internal-network connectors. Docs: [docs.app.strix.ai](https://docs.app.strix.ai). Full workflow in the **managed-pentesting-with-strix** skill.
|
||||
|
||||
## Which one? (decide, don't default)
|
||||
|
||||
@@ -112,7 +112,7 @@ Artifacts land in `strix_runs/<run-name>/`:
|
||||
|
||||
# Option B — Cloud API (managed, no local infra)
|
||||
|
||||
Full details, asset registration, polling, reports, PR reviews, schedules, and webhooks are in the **strix-cloud-api** skill. Minimal launch-and-poll:
|
||||
Full details, asset registration, polling, reports, PR reviews, schedules, and webhooks are in the **managed-pentesting-with-strix** skill. Minimal launch-and-poll:
|
||||
|
||||
```bash
|
||||
export STRIX_API_TOKEN="<token>" # org-scoped bearer, from Settings → API Access at app.strix.ai
|
||||
@@ -136,7 +136,7 @@ Ask the user to create the token (and register the target as a domain/repository
|
||||
|
||||
## Reporting & next steps
|
||||
|
||||
Summarize findings by severity (critical/high/medium/low/info) and include the PoC evidence. To remediate and verify fixes (via either path), use the **strix-fix-findings** skill. To wire scanning into CI/CD, use the **strix-ci-setup** skill.
|
||||
Summarize findings by severity (critical/high/medium/low/info) and include the PoC evidence. To remediate and verify fixes (via either path), use the **fix-security-vulnerabilities-with-strix** skill. To wire scanning into CI/CD, use the **ci-security-scanning-with-strix** skill.
|
||||
|
||||
## Safety
|
||||
|
||||
@@ -490,8 +490,10 @@ Default user: pentester (sudo available)
|
||||
<available_skills>
|
||||
On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `<specialized_knowledge>` above is already loaded for you.
|
||||
|
||||
{% for category, names in available_skills | dictsort -%}
|
||||
- {{ category }}: {{ names | join(', ') }}
|
||||
{% for category, skills in available_skills | dictsort -%}
|
||||
{% for skill in skills -%}
|
||||
- {{ category }}/{{ skill.name }}{% if skill.description %}: {{ skill.description }}{% endif %}
|
||||
{% endfor -%}
|
||||
{% endfor -%}
|
||||
</available_skills>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/usestrix/strix/tui/internal/protocol"
|
||||
)
|
||||
|
||||
func findingsModel(t *testing.T, titles ...string) Model {
|
||||
t.Helper()
|
||||
m := New(nil)
|
||||
m.width, m.height = 130, 30
|
||||
m.showSplash = false
|
||||
m.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"}))
|
||||
items := make([]json.RawMessage, 0, len(titles))
|
||||
for i, title := range titles {
|
||||
items = append(items, rawJSON(t, map[string]any{
|
||||
"id": string(rune('a' + i)), "title": title, "severity": "high",
|
||||
}))
|
||||
}
|
||||
m.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap",
|
||||
Payload: rawJSON(t, protocol.CollectionBootstrap{
|
||||
Collection: "vulnerabilities", Revision: 1, Cursor: 0,
|
||||
NextCursor: len(items), Done: true, Items: items,
|
||||
})})
|
||||
m.resizeViewport()
|
||||
return m
|
||||
}
|
||||
|
||||
// The list scrolls by row, not by finding. Stepping a whole entry at a time is
|
||||
// what made a list of wrapped titles feel paginated.
|
||||
func TestFindingsScrollByRow(t *testing.T) {
|
||||
long := "A deliberately long finding title that wraps across several rows in the sidebar"
|
||||
m := findingsModel(t, long, long, long)
|
||||
|
||||
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||
if len(rows) <= 3 {
|
||||
t.Fatalf("titles did not wrap, so this proves nothing: %d rows", len(rows))
|
||||
}
|
||||
total, offset := m.vulnerabilityScrollRows()
|
||||
if total != len(rows) || offset != 0 {
|
||||
t.Fatalf("scroll metrics are not in rows: total=%d offset=%d rows=%d", total, offset, len(rows))
|
||||
}
|
||||
|
||||
// One step of the offset moves one row, and the first visible line follows it.
|
||||
first := strings.Split(ansi.Strip(m.vulnerabilitiesView(40, 4)), "\n")[0]
|
||||
m.vulnOffset = 1
|
||||
second := strings.Split(ansi.Strip(m.vulnerabilitiesView(40, 4)), "\n")[0]
|
||||
if first == second {
|
||||
t.Fatalf("advancing one row did not move the list: %q", first)
|
||||
}
|
||||
// That row still belongs to the first finding, which an item-stepping list
|
||||
// would have skipped past entirely.
|
||||
if got := m.vulnerabilityIndexAtRow(0); got != 0 {
|
||||
t.Fatalf("one row in, the top line belongs to finding %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Selecting a finding scrolls the least it can, and never past its own start.
|
||||
func TestSelectingAFindingBringsItIntoView(t *testing.T) {
|
||||
long := "A deliberately long finding title that wraps across several rows in the sidebar"
|
||||
m := findingsModel(t, long, long, long, long)
|
||||
|
||||
m.selectedVuln = 3
|
||||
m.ensureVulnerabilityVisible()
|
||||
|
||||
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||
height := m.vulnerabilityPageSize()
|
||||
end := min(len(rows), m.vulnOffset+height)
|
||||
found := false
|
||||
for _, row := range rows[m.vulnOffset:end] {
|
||||
if row.index == 3 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the selected finding is not on screen: offset=%d height=%d", m.vulnOffset, height)
|
||||
}
|
||||
if m.vulnOffset > len(rows)-height && len(rows) > height {
|
||||
t.Fatalf("scrolled past the end: offset=%d rows=%d height=%d", m.vulnOffset, len(rows), height)
|
||||
}
|
||||
}
|
||||
|
||||
func reportModel(t *testing.T, count int) Model {
|
||||
t.Helper()
|
||||
titles := make([]string, 0, count)
|
||||
for i := range count {
|
||||
titles = append(titles, fmt.Sprintf("Finding number %d", i+1))
|
||||
}
|
||||
m := findingsModel(t, titles...)
|
||||
m.openModal(modalVulnerability)
|
||||
return m
|
||||
}
|
||||
|
||||
// The open report can be stepped through the list without closing it.
|
||||
func TestReportStepsBetweenFindings(t *testing.T) {
|
||||
m := reportModel(t, 3)
|
||||
|
||||
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyRight})
|
||||
m = updated.(Model)
|
||||
if m.selectedVuln != 1 {
|
||||
t.Fatalf("right moved to %d, want 1", m.selectedVuln)
|
||||
}
|
||||
if m.modal != modalVulnerability {
|
||||
t.Fatal("stepping closed the report")
|
||||
}
|
||||
updated, _ = m.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
m = updated.(Model)
|
||||
if m.selectedVuln != 0 {
|
||||
t.Fatalf("left moved to %d, want 0", m.selectedVuln)
|
||||
}
|
||||
}
|
||||
|
||||
// The ends do not wrap: rolling from the last report to the first would hide
|
||||
// that you had reached the end.
|
||||
func TestReportStepsStopAtTheEnds(t *testing.T) {
|
||||
m := reportModel(t, 3)
|
||||
|
||||
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
m = updated.(Model)
|
||||
if m.selectedVuln != 0 {
|
||||
t.Fatalf("left from the first report moved to %d, want 0", m.selectedVuln)
|
||||
}
|
||||
|
||||
m.selectedVuln = 2
|
||||
updated, _ = m.updateModal(tea.KeyMsg{Type: tea.KeyRight})
|
||||
m = updated.(Model)
|
||||
if m.selectedVuln != 2 {
|
||||
t.Fatalf("right from the last report moved to %d, want 2", m.selectedVuln)
|
||||
}
|
||||
}
|
||||
|
||||
// Each direction is offered only when there is a report that way, and a lone
|
||||
// finding is offered neither.
|
||||
func TestReportNavigationHintsFollowAvailability(t *testing.T) {
|
||||
m := reportModel(t, 3)
|
||||
for _, testCase := range []struct {
|
||||
index int
|
||||
wantPrev, wantNext bool
|
||||
position string
|
||||
}{
|
||||
{index: 0, wantNext: true, position: "1/3"},
|
||||
{index: 1, wantPrev: true, wantNext: true, position: "2/3"},
|
||||
{index: 2, wantPrev: true, position: "3/3"},
|
||||
} {
|
||||
m.selectedVuln = testCase.index
|
||||
view := ansi.Strip(m.modalView())
|
||||
if !strings.Contains(view, testCase.position) {
|
||||
t.Fatalf("report %d does not show %q", testCase.index, testCase.position)
|
||||
}
|
||||
if got := strings.Contains(view, reportPrev); got != testCase.wantPrev {
|
||||
t.Fatalf("report %d prev hint = %v, want %v", testCase.index, got, testCase.wantPrev)
|
||||
}
|
||||
if got := strings.Contains(view, reportNext); got != testCase.wantNext {
|
||||
t.Fatalf("report %d next hint = %v, want %v", testCase.index, got, testCase.wantNext)
|
||||
}
|
||||
}
|
||||
|
||||
lone := reportModel(t, 1)
|
||||
view := ansi.Strip(lone.modalView())
|
||||
if strings.Contains(view, reportPrev) || strings.Contains(view, reportNext) || strings.Contains(view, "1/1") {
|
||||
t.Fatalf("a lone finding offered navigation:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
// A new report opens at its top, and the copy state does not carry over.
|
||||
func TestSteppingResetsTheReportView(t *testing.T) {
|
||||
m := reportModel(t, 3)
|
||||
m.vulnerabilityCopied = true
|
||||
m.vulnViewport.SetYOffset(3)
|
||||
|
||||
m.showVulnerability(1)
|
||||
|
||||
if m.vulnViewport.YOffset != 0 {
|
||||
t.Fatalf("the next report opened scrolled to %d", m.vulnViewport.YOffset)
|
||||
}
|
||||
if m.vulnerabilityCopied {
|
||||
t.Fatal("the copy state carried over to another report")
|
||||
}
|
||||
}
|
||||
|
||||
// Prev and Next are buttons, not just key hints: they can be clicked.
|
||||
func TestReportStepButtonsAreClickable(t *testing.T) {
|
||||
m := reportModel(t, 3)
|
||||
m.selectedVuln = 1
|
||||
|
||||
click := func(label string) Model {
|
||||
t.Helper()
|
||||
view := m.modalView()
|
||||
left, top, _, _ := m.centeredViewBounds(view)
|
||||
for row, line := range strings.Split(view, "\n") {
|
||||
plain := ansi.Strip(line)
|
||||
index := strings.Index(plain, label)
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
updated, _ := m.updateModalMouse(tea.MouseMsg{
|
||||
X: left + ansi.StringWidth(plain[:index]) + 1, Y: top + row,
|
||||
Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
|
||||
})
|
||||
return updated.(Model)
|
||||
}
|
||||
t.Fatalf("%q was not rendered", label)
|
||||
return m
|
||||
}
|
||||
|
||||
if got := click(reportNext).selectedVuln; got != 2 {
|
||||
t.Fatalf("clicking Next selected %d, want 2", got)
|
||||
}
|
||||
if got := click(reportPrev).selectedVuln; got != 0 {
|
||||
t.Fatalf("clicking Prev selected %d, want 0", got)
|
||||
}
|
||||
if got := click(reportNext).modal; got != modalVulnerability {
|
||||
t.Fatalf("clicking Next closed the report: modal=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Tab walks the whole row, so the step buttons are reachable from the keyboard
|
||||
// as well, and Enter presses whichever one is focused.
|
||||
func TestTabReachesTheStepButtons(t *testing.T) {
|
||||
m := reportModel(t, 3)
|
||||
m.selectedVuln = 1
|
||||
|
||||
if got := m.focusedReportButton(); got != reportDone {
|
||||
t.Fatalf("the report opened focused on %q, want %q", got, reportDone)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for range len(m.reportButtons()) {
|
||||
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = updated.(Model)
|
||||
seen[m.focusedReportButton()] = true
|
||||
}
|
||||
for _, want := range []string{reportPrev, reportNext, reportCopy, reportDone} {
|
||||
if !seen[want] {
|
||||
t.Fatalf("tab never reached %q: %v", want, seen)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter on a focused step button steps.
|
||||
m.reportFocus = reportNext
|
||||
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if got := updated.(Model).selectedVuln; got != 2 {
|
||||
t.Fatalf("enter on Next selected %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Stepping to an end drops that button from the row; focus must not be stranded
|
||||
// on it.
|
||||
func TestFocusFallsBackWhenAStepButtonDisappears(t *testing.T) {
|
||||
m := reportModel(t, 2)
|
||||
m.selectedVuln = 0
|
||||
m.reportFocus = reportNext
|
||||
|
||||
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = updated.(Model)
|
||||
|
||||
if m.selectedVuln != 1 {
|
||||
t.Fatalf("enter on Next selected %d, want 1", m.selectedVuln)
|
||||
}
|
||||
// Next is gone at the last report, so the focus cannot still be on it.
|
||||
if got := m.focusedReportButton(); got == reportNext {
|
||||
t.Fatalf("focus stayed on a button that is no longer shown: %q", got)
|
||||
}
|
||||
if got := m.focusedReportButton(); got != reportDone {
|
||||
t.Fatalf("focus fell back to %q, want %q", got, reportDone)
|
||||
}
|
||||
}
|
||||
|
||||
// The list must be laid out at one width. Rendering at one and hit-testing at
|
||||
// another gives two different row counts for the same title, and then a click
|
||||
// resolves to the wrong finding and the scrollbar reports the wrong length.
|
||||
func TestFindingsUseOneWidthForRenderAndInteraction(t *testing.T) {
|
||||
// This title wraps to one row at 21 columns and two at 20, which is exactly
|
||||
// the pair of widths the two paths used to disagree on.
|
||||
m := findingsModel(t, "ffffff dddd a a a a", "eeeee eeeee a a a a", "header dddd a a a a")
|
||||
|
||||
width := m.vulnerabilityListWidth()
|
||||
rows := m.vulnerabilityRows(width)
|
||||
rendered := strings.Split(ansi.Strip(m.vulnerabilitiesView(width, len(rows))), "\n")
|
||||
|
||||
if len(rendered) != len(rows) {
|
||||
t.Fatalf("rendered %d rows, interaction counts %d", len(rendered), len(rows))
|
||||
}
|
||||
for row := range rendered {
|
||||
if got := m.vulnerabilityIndexAtRow(row); got != rows[row].index {
|
||||
t.Fatalf("row %d shows finding %d but a click resolves to %d",
|
||||
row, rows[row].index, got)
|
||||
}
|
||||
}
|
||||
if total, _ := m.vulnerabilityScrollRows(); total != len(rendered) {
|
||||
t.Fatalf("the scrollbar reports %d rows, %d are rendered", total, len(rendered))
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ type Model struct {
|
||||
agentOffset int
|
||||
vulnOffset int
|
||||
modalChoice int
|
||||
reportFocus string
|
||||
ready bool
|
||||
quitting bool
|
||||
showSplash bool
|
||||
@@ -157,12 +158,16 @@ const (
|
||||
treeCursorBg = lipgloss.Color("#0178d4")
|
||||
)
|
||||
|
||||
// Scrollbar thumbs. Each panel keeps its own, and the track stays blank so a
|
||||
// scrollable panel does not gain a visible rule down its edge.
|
||||
// Scrollbar thumbs. The track stays blank so a scrollable panel does not gain a
|
||||
// visible rule down its edge, and the thumb brightens while it is dragged, which
|
||||
// is the feedback Textual gave through scrollbar-color-active.
|
||||
//
|
||||
// One resting color for every panel, rather than the three the stylesheet named.
|
||||
// The chat pane's was #1a1a1a on black, which is invisible - the bar could not be
|
||||
// found, let alone grabbed (#1005).
|
||||
const (
|
||||
thumbTrace = lipgloss.Color("#1a1a1a")
|
||||
thumbAgents = lipgloss.Color("#404040")
|
||||
thumbFindings = lipgloss.Color("#333333")
|
||||
thumbResting = lipgloss.Color("#3f3f46")
|
||||
thumbActive = lipgloss.Color("#9ca3af")
|
||||
)
|
||||
|
||||
// Composer placeholders. The launch screen falls back to the short prompt when
|
||||
|
||||
@@ -527,7 +527,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
|
||||
}
|
||||
|
||||
model := newModel()
|
||||
updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
// Tab moves between the buttons; the arrows step between reports.
|
||||
updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyTab})
|
||||
model = updated.(Model)
|
||||
updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
@@ -560,8 +561,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
|
||||
X: copyX, Y: copyY, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
|
||||
})
|
||||
model = updated.(Model)
|
||||
if cmd == nil || model.modalChoice != 0 {
|
||||
t.Fatalf("mouse Copy was not activated: choice=%d cmd=%v", model.modalChoice, cmd)
|
||||
if cmd == nil || model.reportFocus != reportCopy {
|
||||
t.Fatalf("mouse Copy was not activated: focus=%q cmd=%v", model.reportFocus, cmd)
|
||||
}
|
||||
cmd()
|
||||
if len(copied) != 2 {
|
||||
@@ -820,8 +821,8 @@ func TestRunningViewerShowsCompleteWrappedURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
|
||||
top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbAgents)), "\n")
|
||||
bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbAgents)), "\n")
|
||||
top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbResting)), "\n")
|
||||
bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbResting)), "\n")
|
||||
|
||||
// The track is blank, so only the thumb is drawn.
|
||||
if top[0] != "█" || top[5] != " " {
|
||||
@@ -830,10 +831,10 @@ func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
|
||||
if bottom[0] != " " || bottom[5] != "█" {
|
||||
t.Fatalf("bottom scrollbar is incorrect: %#v", bottom)
|
||||
}
|
||||
if full := verticalScrollbar(4, 4, 4, 0, thumbAgents); full != "" {
|
||||
if full := verticalScrollbar(4, 4, 4, 0, thumbResting); full != "" {
|
||||
t.Fatalf("non-overflowing scrollbar should be hidden: %q", full)
|
||||
}
|
||||
withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbAgents))
|
||||
withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbResting))
|
||||
if strings.ContainsAny(withoutBar, "█") {
|
||||
t.Fatalf("non-overflowing panel rendered a scrollbar: %q", withoutBar)
|
||||
}
|
||||
@@ -841,7 +842,7 @@ func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
|
||||
|
||||
// The bar takes exactly one column, so a scrolling panel keeps the rest.
|
||||
func TestVerticalScrollbarOccupiesOneColumn(t *testing.T) {
|
||||
rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbTrace), "\n")
|
||||
rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbResting), "\n")
|
||||
for _, row := range rows {
|
||||
if width := ansi.StringWidth(row); width != 12 {
|
||||
t.Fatalf("scrolling panel row width = %d, want 12", width)
|
||||
|
||||
@@ -219,7 +219,8 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
|
||||
m.focus = focusVulnerabilities
|
||||
m.input.Blur()
|
||||
m.vulnOffset = min(max(0, len(m.snapshot.Vulnerabilities)-1), m.vulnOffset+3)
|
||||
totalRows, _ := m.vulnerabilityScrollRows()
|
||||
m.vulnOffset = min(max(0, totalRows-m.vulnerabilityPageSize()), m.vulnOffset+3)
|
||||
m.keepVulnerabilitySelectionInWindow()
|
||||
}
|
||||
return m, nil
|
||||
@@ -318,24 +319,7 @@ func (m *Model) updateMainScrollbarMouse(
|
||||
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
|
||||
return false
|
||||
}
|
||||
|
||||
target := scrollbarNone
|
||||
switch {
|
||||
case msg.X == chatWidth-2 && msg.Y >= 1 && msg.Y < chatHeight-1 &&
|
||||
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
|
||||
target = scrollbarTrace
|
||||
case showSidebar && msg.X == m.width-3 && msg.Y >= viewerHeight+2 &&
|
||||
msg.Y < viewerHeight+agentHeight-2 &&
|
||||
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
|
||||
target = scrollbarAgents
|
||||
case showSidebar && vulnHeight > 0 && msg.X == m.width-3 &&
|
||||
msg.Y >= viewerHeight+agentHeight+1 &&
|
||||
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
|
||||
totalRows, _ := m.vulnerabilityScrollRows()
|
||||
if totalRows > m.vulnerabilityPageSize() {
|
||||
target = scrollbarFindings
|
||||
}
|
||||
}
|
||||
target := m.scrollbarAt(msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight)
|
||||
if target == scrollbarNone {
|
||||
return false
|
||||
}
|
||||
@@ -344,6 +328,40 @@ func (m *Model) updateMainScrollbarMouse(
|
||||
return true
|
||||
}
|
||||
|
||||
// scrollbarGrab is how far either side of the bar still counts as grabbing it. A
|
||||
// one column target is unreasonable to hit with a mouse, and nothing else lives
|
||||
// in the column beside it.
|
||||
const scrollbarGrab = 1
|
||||
|
||||
func nearColumn(x, column int) bool {
|
||||
return x >= column-scrollbarGrab && x <= column+scrollbarGrab
|
||||
}
|
||||
|
||||
// scrollbarAt reports which scrollbar, if any, the pointer is over.
|
||||
func (m Model) scrollbarAt(
|
||||
msg tea.MouseMsg,
|
||||
showSidebar bool,
|
||||
chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight int,
|
||||
) scrollbarTarget {
|
||||
switch {
|
||||
case nearColumn(msg.X, chatWidth-2) && msg.Y >= 1 && msg.Y < chatHeight-1 &&
|
||||
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
|
||||
return scrollbarTrace
|
||||
case showSidebar && nearColumn(msg.X, m.width-3) && msg.Y >= viewerHeight+2 &&
|
||||
msg.Y < viewerHeight+agentHeight-2 &&
|
||||
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
|
||||
return scrollbarAgents
|
||||
case showSidebar && vulnHeight > 0 && nearColumn(msg.X, m.width-3) &&
|
||||
msg.Y >= viewerHeight+agentHeight+1 &&
|
||||
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
|
||||
totalRows, _ := m.vulnerabilityScrollRows()
|
||||
if totalRows > m.vulnerabilityPageSize() {
|
||||
return scrollbarFindings
|
||||
}
|
||||
}
|
||||
return scrollbarNone
|
||||
}
|
||||
|
||||
func (m *Model) scrollFromMouse(
|
||||
target scrollbarTarget,
|
||||
y, chatHeight, viewerHeight, agentHeight int,
|
||||
@@ -367,10 +385,10 @@ func (m *Model) scrollFromMouse(
|
||||
case scrollbarFindings:
|
||||
height := m.vulnerabilityPageSize()
|
||||
totalRows, _ := m.vulnerabilityScrollRows()
|
||||
rowOffset := scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
|
||||
m.focus = focusVulnerabilities
|
||||
m.input.Blur()
|
||||
m.vulnOffset = m.vulnerabilityOffsetAtRow(rowOffset)
|
||||
// The offset is a row, so dragging moves the list continuously.
|
||||
m.vulnOffset = scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
|
||||
m.keepVulnerabilitySelectionInWindow()
|
||||
}
|
||||
}
|
||||
@@ -405,6 +423,22 @@ func (m Model) updateSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// pressReportButton performs a button of the report row, however it was reached.
|
||||
func (m Model) pressReportButton(button string) (tea.Model, tea.Cmd) {
|
||||
switch button {
|
||||
case reportPrev:
|
||||
m.showVulnerability(m.selectedVuln - 1)
|
||||
case reportNext:
|
||||
m.showVulnerability(m.selectedVuln + 1)
|
||||
case reportCopy:
|
||||
m.reportFocus = reportCopy
|
||||
return m, m.startVulnerabilityCopy()
|
||||
default:
|
||||
m.closeModal()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.modal == modalVulnerability {
|
||||
view := m.modalView()
|
||||
@@ -441,13 +475,22 @@ func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
}
|
||||
case modalVulnerability:
|
||||
for _, button := range m.reportButtons() {
|
||||
if button == reportCopy || button == reportDone {
|
||||
continue
|
||||
}
|
||||
if m.centeredLabelHit(view, button, msg.X, msg.Y) {
|
||||
m.reportFocus = button
|
||||
return m.pressReportButton(button)
|
||||
}
|
||||
}
|
||||
if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) {
|
||||
m.modalChoice = 0
|
||||
m.reportFocus = reportCopy
|
||||
cmd := m.startVulnerabilityCopy()
|
||||
return m, cmd
|
||||
}
|
||||
if m.centeredLabelHit(view, "Done", msg.X, msg.Y) {
|
||||
m.modalChoice = 1
|
||||
m.reportFocus = reportDone
|
||||
m.closeModal()
|
||||
}
|
||||
}
|
||||
@@ -516,16 +559,19 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.closeModal()
|
||||
case "left", "right", "tab", "shift+tab":
|
||||
m.modalChoice = 1 - m.modalChoice
|
||||
// The arrows step between reports directly; tab walks the button row.
|
||||
case "left":
|
||||
m.showVulnerability(m.selectedVuln - 1)
|
||||
case "right":
|
||||
m.showVulnerability(m.selectedVuln + 1)
|
||||
case "tab":
|
||||
m.stepReportFocus(1)
|
||||
case "shift+tab":
|
||||
m.stepReportFocus(-1)
|
||||
case "enter":
|
||||
if m.modalChoice == 0 {
|
||||
cmd := m.startVulnerabilityCopy()
|
||||
return m, cmd
|
||||
}
|
||||
m.closeModal()
|
||||
return m.pressReportButton(m.focusedReportButton())
|
||||
case "c":
|
||||
m.modalChoice = 0
|
||||
m.reportFocus = reportCopy
|
||||
cmd := m.startVulnerabilityCopy()
|
||||
return m, cmd
|
||||
case "up":
|
||||
@@ -584,6 +630,7 @@ func (m *Model) openModal(mode modalMode) {
|
||||
m.modalChoice = 1
|
||||
}
|
||||
if mode == modalVulnerability {
|
||||
m.reportFocus = reportDone
|
||||
m.modalChoice = 1
|
||||
m.vulnerabilityCopied = false
|
||||
m.vulnerabilityCopyError = ""
|
||||
|
||||
@@ -164,6 +164,15 @@ func wrapBlock(value string, width int) string {
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// scrollbarThumb brightens the bar being dragged so the grab reads as taking
|
||||
// hold of it.
|
||||
func (m Model) scrollbarThumb(target scrollbarTarget) lipgloss.Color {
|
||||
if m.draggingScrollbar == target {
|
||||
return thumbActive
|
||||
}
|
||||
return thumbResting
|
||||
}
|
||||
|
||||
func verticalScrollbar(height, total, visible, offset int, thumb lipgloss.Color) string {
|
||||
if height <= 0 || total <= visible {
|
||||
return ""
|
||||
@@ -424,7 +433,7 @@ func (m Model) renderChatPane(width, height int, border lipgloss.Color) string {
|
||||
m.viewport.TotalLineCount(),
|
||||
m.viewport.VisibleLineCount(),
|
||||
m.viewport.YOffset,
|
||||
thumbTrace,
|
||||
m.scrollbarThumb(scrollbarTrace),
|
||||
)
|
||||
out := lipgloss.NewStyle().Width(width).Height(height).
|
||||
Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace)
|
||||
@@ -489,7 +498,7 @@ func (m Model) sidebarView(width, height int) string {
|
||||
len(agentEntries),
|
||||
agentRows,
|
||||
m.agentOffset,
|
||||
thumbAgents,
|
||||
m.scrollbarThumb(scrollbarAgents),
|
||||
)
|
||||
parts := []string{
|
||||
lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)),
|
||||
@@ -503,13 +512,13 @@ func (m Model) sidebarView(width, height int) string {
|
||||
vulnRows := max(1, vulnHeight-2)
|
||||
totalRows, offsetRows := m.vulnerabilityScrollRows()
|
||||
findings := withVerticalScrollbar(
|
||||
m.vulnerabilitiesView(max(1, width-5), vulnRows),
|
||||
m.vulnerabilitiesView(m.vulnerabilityListWidth(), vulnRows),
|
||||
width-4,
|
||||
vulnRows,
|
||||
totalRows,
|
||||
vulnRows,
|
||||
offsetRows,
|
||||
thumbFindings,
|
||||
m.scrollbarThumb(scrollbarFindings),
|
||||
)
|
||||
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings))
|
||||
}
|
||||
@@ -524,12 +533,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, agentHeight int) {
|
||||
statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView()))
|
||||
statsHeight = min(15, statsRows+2)
|
||||
if len(m.snapshot.Vulnerabilities) > 0 {
|
||||
rows := 0
|
||||
width := m.vulnerabilityListWidth()
|
||||
for i := range m.snapshot.Vulnerabilities {
|
||||
rows += len(m.vulnerabilityTitleLines(i, width))
|
||||
}
|
||||
vulnHeight = min(12, rows+2)
|
||||
vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2)
|
||||
}
|
||||
agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -13,115 +14,123 @@ var panelSeverityColors = map[string]lipgloss.Color{
|
||||
"critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue,
|
||||
}
|
||||
|
||||
func (m Model) vulnerabilitiesView(width, height int) string {
|
||||
var lines []string
|
||||
start := min(max(0, m.vulnOffset), max(0, len(m.snapshot.Vulnerabilities)-1))
|
||||
for i := start; i < len(m.snapshot.Vulnerabilities) && len(lines) < height; i++ {
|
||||
vuln := m.snapshot.Vulnerabilities[i]
|
||||
severity := strings.ToLower(render.StringValue(vuln["severity"]))
|
||||
color, ok := panelSeverityColors[severity]
|
||||
if !ok {
|
||||
color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6")
|
||||
// vulnerabilityRow is one rendered line of the findings list. The list scrolls by
|
||||
// row rather than by finding, so a long title does not make the panel jump a
|
||||
// whole entry at a time.
|
||||
type vulnerabilityRow struct {
|
||||
index int // the finding this line belongs to
|
||||
text string // one wrapped line of its title
|
||||
first bool // the line that carries the number and the severity dot
|
||||
}
|
||||
|
||||
// vulnerabilityRows lays every finding out as the lines it will occupy.
|
||||
func (m Model) vulnerabilityRows(width int) []vulnerabilityRow {
|
||||
// Wrapped lines sit under the title rather than under the severity dot.
|
||||
body := max(1, width-2)
|
||||
rows := make([]vulnerabilityRow, 0, len(m.snapshot.Vulnerabilities))
|
||||
for i := range m.snapshot.Vulnerabilities {
|
||||
for line, text := range strings.Split(wrapBlock(m.vulnerabilityTitle(i), body), "\n") {
|
||||
rows = append(rows, vulnerabilityRow{index: i, text: text, first: line == 0})
|
||||
}
|
||||
marker := lipgloss.NewStyle().Foreground(color).Render("● ")
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (m Model) vulnerabilitiesView(width, height int) string {
|
||||
rows := m.vulnerabilityRows(width)
|
||||
start := min(max(0, m.vulnOffset), max(0, len(rows)-1))
|
||||
end := min(len(rows), start+height)
|
||||
lines := make([]string, 0, max(0, end-start))
|
||||
for _, row := range rows[start:end] {
|
||||
style := lipgloss.NewStyle().Foreground(textColor)
|
||||
if i == m.selectedVuln {
|
||||
if row.index == m.selectedVuln {
|
||||
style = style.Bold(true).Foreground(white)
|
||||
}
|
||||
for row, titleLine := range m.vulnerabilityTitleLines(i, width) {
|
||||
if len(lines) >= height {
|
||||
break
|
||||
prefix := " "
|
||||
if row.first {
|
||||
severity := strings.ToLower(render.StringValue(m.snapshot.Vulnerabilities[row.index]["severity"]))
|
||||
color, ok := panelSeverityColors[severity]
|
||||
if !ok {
|
||||
color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6")
|
||||
}
|
||||
prefix := " "
|
||||
if row == 0 {
|
||||
prefix = marker
|
||||
}
|
||||
lines = append(lines, prefix+style.Render(titleLine))
|
||||
prefix = lipgloss.NewStyle().Foreground(color).Render("● ")
|
||||
}
|
||||
lines = append(lines, prefix+style.Render(row.text))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// vulnerabilityListWidth is the one width the findings list is laid out at, for
|
||||
// rendering and for every interaction alike. Wrapping a title at two widths a
|
||||
// column apart gives two different row counts, and then a click resolves to the
|
||||
// wrong finding and the scrollbar reports the wrong length.
|
||||
//
|
||||
// The panel is sidebarWidth-2 wide with a column of padding either side, and the
|
||||
// scrollbar takes one more. That last column is reserved whether or not the bar
|
||||
// is showing, so the layout does not shift as the list grows past the panel.
|
||||
func (m Model) vulnerabilityListWidth() int {
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
return max(1, sidebarWidth-6)
|
||||
return max(1, sidebarWidth-5)
|
||||
}
|
||||
|
||||
func (m Model) vulnerabilityTitleLines(index, width int) []string {
|
||||
func (m Model) vulnerabilityTitle(index int) string {
|
||||
title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"])
|
||||
if title == "" {
|
||||
title = "Unknown Vulnerability"
|
||||
}
|
||||
return strings.Split(wrapBlock(title, max(1, width-2)), "\n")
|
||||
return title
|
||||
}
|
||||
|
||||
// vulnerabilityScrollRows reports the list length and position in rows, which is
|
||||
// what the scrollbar needs to move continuously.
|
||||
func (m Model) vulnerabilityScrollRows() (total, offset int) {
|
||||
width := m.vulnerabilityListWidth()
|
||||
for i := range m.snapshot.Vulnerabilities {
|
||||
rows := len(m.vulnerabilityTitleLines(i, width))
|
||||
total += rows
|
||||
if i < m.vulnOffset {
|
||||
offset += rows
|
||||
}
|
||||
}
|
||||
return total, offset
|
||||
}
|
||||
|
||||
func (m Model) vulnerabilityOffsetAtRow(targetRow int) int {
|
||||
width := m.vulnerabilityListWidth()
|
||||
row := 0
|
||||
for i := range m.snapshot.Vulnerabilities {
|
||||
row += len(m.vulnerabilityTitleLines(i, width))
|
||||
if targetRow < row {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return max(0, len(m.snapshot.Vulnerabilities)-1)
|
||||
}
|
||||
|
||||
func (m Model) vulnerabilityVisibleEnd(start int) int {
|
||||
height := m.vulnerabilityPageSize()
|
||||
width := m.vulnerabilityListWidth()
|
||||
rows := 0
|
||||
end := min(max(0, start), len(m.snapshot.Vulnerabilities))
|
||||
for end < len(m.snapshot.Vulnerabilities) {
|
||||
itemRows := len(m.vulnerabilityTitleLines(end, width))
|
||||
if rows > 0 && rows+itemRows > height {
|
||||
break
|
||||
}
|
||||
rows += itemRows
|
||||
end++
|
||||
if rows >= height {
|
||||
break
|
||||
}
|
||||
}
|
||||
return end
|
||||
return len(m.vulnerabilityRows(m.vulnerabilityListWidth())), m.vulnOffset
|
||||
}
|
||||
|
||||
// vulnerabilityIndexAtRow maps a click on a visible row back to its finding.
|
||||
func (m Model) vulnerabilityIndexAtRow(row int) int {
|
||||
width := m.vulnerabilityListWidth()
|
||||
currentRow := 0
|
||||
for i := m.vulnOffset; i < m.vulnerabilityVisibleEnd(m.vulnOffset); i++ {
|
||||
currentRow += len(m.vulnerabilityTitleLines(i, width))
|
||||
if row < currentRow {
|
||||
return i
|
||||
}
|
||||
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||
target := m.vulnOffset + row
|
||||
if target < 0 || target >= len(rows) {
|
||||
return -1
|
||||
}
|
||||
return -1
|
||||
return rows[target].index
|
||||
}
|
||||
|
||||
// ensureVulnerabilityVisible scrolls the least it can to bring the selected
|
||||
// finding into view, keeping the whole entry visible where it fits.
|
||||
func (m *Model) ensureVulnerabilityVisible() {
|
||||
if len(m.snapshot.Vulnerabilities) == 0 {
|
||||
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||
if len(rows) == 0 {
|
||||
m.vulnOffset = 0
|
||||
return
|
||||
}
|
||||
if m.selectedVuln < m.vulnOffset {
|
||||
m.vulnOffset = m.selectedVuln
|
||||
height := m.vulnerabilityPageSize()
|
||||
firstRow, lastRow := -1, -1
|
||||
for row, entry := range rows {
|
||||
if entry.index != m.selectedVuln {
|
||||
continue
|
||||
}
|
||||
if firstRow < 0 {
|
||||
firstRow = row
|
||||
}
|
||||
lastRow = row
|
||||
}
|
||||
for m.selectedVuln >= m.vulnerabilityVisibleEnd(m.vulnOffset) && m.vulnOffset < m.selectedVuln {
|
||||
m.vulnOffset++
|
||||
if firstRow < 0 {
|
||||
m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height)
|
||||
return
|
||||
}
|
||||
m.vulnOffset = min(m.vulnOffset, len(m.snapshot.Vulnerabilities)-1)
|
||||
if firstRow < m.vulnOffset {
|
||||
m.vulnOffset = firstRow
|
||||
} else if lastRow >= m.vulnOffset+height {
|
||||
// Prefer showing the whole entry, but never scroll its start out of view.
|
||||
m.vulnOffset = min(firstRow, lastRow-height+1)
|
||||
}
|
||||
m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height)
|
||||
}
|
||||
|
||||
func clampVulnerabilityOffset(offset, total, height int) int {
|
||||
return min(max(0, offset), max(0, total-height))
|
||||
}
|
||||
|
||||
func (m Model) vulnerabilityPageSize() int {
|
||||
@@ -129,23 +138,52 @@ func (m Model) vulnerabilityPageSize() int {
|
||||
return max(1, vulnHeight-2)
|
||||
}
|
||||
|
||||
// vulnerabilityPageItems is how many findings a page step should move by: the
|
||||
// number of distinct entries currently on screen.
|
||||
func (m Model) vulnerabilityPageItems() int {
|
||||
return max(1, m.vulnerabilityVisibleEnd(m.vulnOffset)-m.vulnOffset)
|
||||
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||
height := m.vulnerabilityPageSize()
|
||||
start := min(max(0, m.vulnOffset), max(0, len(rows)))
|
||||
end := min(len(rows), start+height)
|
||||
seen := 0
|
||||
previous := -1
|
||||
for _, row := range rows[start:end] {
|
||||
if row.index != previous {
|
||||
seen++
|
||||
previous = row.index
|
||||
}
|
||||
}
|
||||
return max(1, seen)
|
||||
}
|
||||
|
||||
func (m *Model) moveVulnerabilitySelection(delta int) {
|
||||
m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta))
|
||||
}
|
||||
|
||||
// keepVulnerabilitySelectionInWindow pulls the selection to the nearest finding
|
||||
// still on screen after the list has been scrolled directly.
|
||||
func (m *Model) keepVulnerabilitySelectionInWindow() {
|
||||
if len(m.snapshot.Vulnerabilities) == 0 {
|
||||
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
if m.selectedVuln < m.vulnOffset {
|
||||
m.selectedVuln = m.vulnOffset
|
||||
} else if end := m.vulnerabilityVisibleEnd(m.vulnOffset); m.selectedVuln >= end {
|
||||
m.selectedVuln = max(m.vulnOffset, end-1)
|
||||
height := m.vulnerabilityPageSize()
|
||||
start := min(max(0, m.vulnOffset), max(0, len(rows)-1))
|
||||
end := min(len(rows), start+height)
|
||||
visible := rows[start:end]
|
||||
if len(visible) == 0 {
|
||||
return
|
||||
}
|
||||
for _, row := range visible {
|
||||
if row.index == m.selectedVuln {
|
||||
return
|
||||
}
|
||||
}
|
||||
if m.selectedVuln < visible[0].index {
|
||||
m.selectedVuln = visible[0].index
|
||||
return
|
||||
}
|
||||
m.selectedVuln = visible[len(visible)-1].index
|
||||
}
|
||||
|
||||
// statsView ports build_tui_stats_text + the version line appended in
|
||||
@@ -373,25 +411,116 @@ func (m Model) vulnerabilityDetail() string {
|
||||
inner := max(1, width-8)
|
||||
// Button row: right-aligned Copy / Done above a top rule (#vuln_detail_buttons).
|
||||
rule := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a1a")).Render(strings.Repeat("─", max(1, inner)))
|
||||
copyLabel := "Copy"
|
||||
if m.vulnerabilityCopied {
|
||||
copyLabel = "Copied!"
|
||||
} else if m.vulnerabilityCopyError != "" {
|
||||
copyLabel = "Copy failed"
|
||||
focused := m.focusedReportButton()
|
||||
var stepping, acting []string
|
||||
for _, button := range m.reportButtons() {
|
||||
rendered := m.reportButton(button, button == focused)
|
||||
if button == reportPrev || button == reportNext {
|
||||
stepping = append(stepping, rendered)
|
||||
continue
|
||||
}
|
||||
acting = append(acting, rendered)
|
||||
}
|
||||
copyButton := lipgloss.NewStyle().Foreground(lipgloss.Color("#525252"))
|
||||
doneButton := lipgloss.NewStyle().Foreground(mid)
|
||||
if m.modalChoice == 0 {
|
||||
copyButton = copyButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1)
|
||||
} else {
|
||||
doneButton = doneButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1)
|
||||
// Stepping sits on the left behind the position, acting on the right.
|
||||
right := strings.Join(acting, " ")
|
||||
left := strings.Join(stepping, " ")
|
||||
if total := len(m.snapshot.Vulnerabilities); total > 1 {
|
||||
left = render.Dim().Render(fmt.Sprintf("%d/%d", m.selectedVuln+1, total)) + " " + left
|
||||
}
|
||||
buttons := copyButton.Render(copyLabel) + " " + doneButton.Render("Done")
|
||||
buttonRow := rule + "\n" + lipgloss.NewStyle().Width(inner).Align(lipgloss.Right).Render(buttons)
|
||||
room := max(0, inner-lipgloss.Width(right))
|
||||
buttonRow := rule + "\n" +
|
||||
lipgloss.NewStyle().Width(room).Render(truncate(left, room)) + right
|
||||
content := m.vulnerabilityScrollView() + "\n" + buttonRow
|
||||
return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#262626")).Background(lipgloss.Color("#0a0a0a")).Padding(2, 3).Render(content)
|
||||
}
|
||||
|
||||
// showVulnerability moves the open report to another finding, keeping the list
|
||||
// behind it in step and starting the new report at its top.
|
||||
func (m *Model) showVulnerability(index int) {
|
||||
if index < 0 || index >= len(m.snapshot.Vulnerabilities) || index == m.selectedVuln {
|
||||
return
|
||||
}
|
||||
m.selectedVuln = index
|
||||
m.ensureVulnerabilityVisible()
|
||||
// The copy state belongs to the report that was on screen, not this one.
|
||||
m.vulnerabilityCopied = false
|
||||
m.vulnerabilityCopyError = ""
|
||||
m.resizeVulnerabilityViewport()
|
||||
m.vulnViewport.GotoTop()
|
||||
}
|
||||
|
||||
// The report's buttons. Prev and Next carry their arrows so a click test cannot
|
||||
// be fooled by the same word appearing in the body of a finding.
|
||||
const (
|
||||
reportPrev = "‹ Prev"
|
||||
reportNext = "Next ›"
|
||||
reportCopy = "Copy"
|
||||
reportDone = "Done"
|
||||
)
|
||||
|
||||
// reportButtons is the row as it stands, left to right. Stepping is offered only
|
||||
// in the directions that have a report.
|
||||
func (m Model) reportButtons() []string {
|
||||
previous, next := m.vulnerabilityNeighbors()
|
||||
buttons := make([]string, 0, 4)
|
||||
if previous {
|
||||
buttons = append(buttons, reportPrev)
|
||||
}
|
||||
if next {
|
||||
buttons = append(buttons, reportNext)
|
||||
}
|
||||
return append(buttons, reportCopy, reportDone)
|
||||
}
|
||||
|
||||
// focusedReportButton is the button Enter would press. It falls back to Done when
|
||||
// the focused one has gone, which happens when stepping to either end drops a
|
||||
// direction from the row.
|
||||
func (m Model) focusedReportButton() string {
|
||||
for _, button := range m.reportButtons() {
|
||||
if button == m.reportFocus {
|
||||
return button
|
||||
}
|
||||
}
|
||||
return reportDone
|
||||
}
|
||||
|
||||
// stepReportFocus moves along the row, wrapping at its ends.
|
||||
func (m *Model) stepReportFocus(delta int) {
|
||||
buttons := m.reportButtons()
|
||||
current := 0
|
||||
for i, button := range buttons {
|
||||
if button == m.focusedReportButton() {
|
||||
current = i
|
||||
}
|
||||
}
|
||||
m.reportFocus = buttons[clampCycle(current+delta, len(buttons))]
|
||||
}
|
||||
|
||||
// vulnerabilityNeighbors reports which way the open report can be stepped. The
|
||||
// ends are not wrapped: a report is one of an ordered list, and rolling from the
|
||||
// last to the first hides that you reached the end.
|
||||
func (m Model) vulnerabilityNeighbors() (previous, next bool) {
|
||||
return m.selectedVuln > 0, m.selectedVuln < len(m.snapshot.Vulnerabilities)-1
|
||||
}
|
||||
|
||||
// reportButton renders one button of the report row. Copy reports the outcome of
|
||||
// the last attempt in its own label.
|
||||
func (m Model) reportButton(label string, focused bool) string {
|
||||
if label == reportCopy {
|
||||
switch {
|
||||
case m.vulnerabilityCopied:
|
||||
label = "Copied!"
|
||||
case m.vulnerabilityCopyError != "":
|
||||
label = "Copy failed"
|
||||
}
|
||||
}
|
||||
if focused {
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color("#363636")).
|
||||
Foreground(brightWhite).Bold(true).Padding(0, 1).Render(label)
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#525252")).Render(label)
|
||||
}
|
||||
|
||||
func (m *Model) startVulnerabilityCopy() tea.Cmd {
|
||||
m.vulnerabilityCopied = false
|
||||
m.vulnerabilityCopyError = ""
|
||||
|
||||
@@ -4,6 +4,9 @@ import threading
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import TypeGuard
|
||||
|
||||
import yaml
|
||||
|
||||
from strix.telemetry import posthog, scarf
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
@@ -11,12 +14,17 @@ from strix.utils.resource_paths import get_strix_resource_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
|
||||
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n", re.DOTALL)
|
||||
|
||||
_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"})
|
||||
_ROOT_SKILL_CATEGORY = "root"
|
||||
|
||||
_EXTRA_SKILL_DIRS: list[Path] = []
|
||||
_SKILL_METADATA_CACHE: dict[tuple[Path, int, int], dict[str, str]] = {}
|
||||
|
||||
|
||||
def _is_frontmatter_mapping(value: object) -> TypeGuard[dict[object, object]]:
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def register_skill_dir(path: str | Path) -> None:
|
||||
@@ -109,13 +117,18 @@ def _get_ambiguous_skill_names() -> set[str]:
|
||||
return {name for name, count in counts.items() if count > 1}
|
||||
|
||||
|
||||
def _qualified_skill_files(skill_name: str) -> list[Path]:
|
||||
def _qualified_skill_file_for_name(skill_name: str) -> Path | None:
|
||||
category, _, name = skill_name.partition("/")
|
||||
for skills_dir in skill_search_dirs():
|
||||
candidate = _qualified_skill_file(skills_dir, category, name)
|
||||
if candidate is not None:
|
||||
return [candidate]
|
||||
return []
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _qualified_skill_files(skill_name: str) -> list[Path]:
|
||||
candidate = _qualified_skill_file_for_name(skill_name)
|
||||
return [candidate] if candidate is not None else []
|
||||
|
||||
|
||||
def _bare_skill_files(skill_name: str) -> list[Path]:
|
||||
@@ -145,10 +158,59 @@ def _bare_skill_files(skill_name: str) -> list[Path]:
|
||||
return candidates
|
||||
|
||||
|
||||
def get_available_skills() -> dict[str, list[str]]:
|
||||
grouped: dict[str, list[str]] = {}
|
||||
def _parse_skill_content(content: str, source: Path | None = None) -> tuple[dict[str, str], str]:
|
||||
"""Parse skill frontmatter once and return metadata plus markdown body."""
|
||||
frontmatter = _FRONTMATTER_PATTERN.match(content)
|
||||
if frontmatter is None:
|
||||
return {}, content.lstrip()
|
||||
|
||||
try:
|
||||
parsed: object = yaml.safe_load(frontmatter.group("body"))
|
||||
except yaml.YAMLError as error:
|
||||
logger.warning("Failed to parse skill frontmatter %s: %s", source or "<content>", error)
|
||||
parsed = None
|
||||
if not _is_frontmatter_mapping(parsed):
|
||||
logger.warning("Skill frontmatter is not a mapping: %s", source or "<content>")
|
||||
return {}, content[frontmatter.end() :].lstrip()
|
||||
|
||||
metadata = {str(key): "" if value is None else str(value) for key, value in parsed.items()}
|
||||
return metadata, content[frontmatter.end() :].lstrip()
|
||||
|
||||
|
||||
def _read_skill_metadata(file_path: Path) -> dict[str, str]:
|
||||
try:
|
||||
stat = file_path.stat()
|
||||
except OSError:
|
||||
logger.warning("Skill file disappeared while reading metadata: %s", file_path)
|
||||
return {}
|
||||
cache_key = (file_path, stat.st_mtime_ns, stat.st_size)
|
||||
cached = _SKILL_METADATA_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
logger.warning("Failed to read skill metadata: %s", file_path)
|
||||
return {}
|
||||
metadata, _ = _parse_skill_content(content, file_path)
|
||||
_SKILL_METADATA_CACHE[cache_key] = metadata
|
||||
return metadata
|
||||
|
||||
|
||||
def get_available_skills() -> dict[str, list[dict[str, str]]]:
|
||||
grouped: dict[str, list[dict[str, str]]] = {}
|
||||
for category, name in _iter_user_skill_files():
|
||||
grouped.setdefault(category, []).append(name)
|
||||
file_path = _qualified_skill_file_for_name(f"{category}/{name}")
|
||||
if file_path is None:
|
||||
logger.warning(
|
||||
"Skill disappeared while gathering available skills: %s/%s",
|
||||
category,
|
||||
name,
|
||||
)
|
||||
continue
|
||||
metadata = _read_skill_metadata(file_path)
|
||||
description = " ".join(metadata.get("description", "").split())
|
||||
grouped.setdefault(category, []).append({"name": name, "description": description})
|
||||
return grouped
|
||||
|
||||
|
||||
@@ -228,7 +290,8 @@ def load_skills(skill_names: list[str]) -> dict[str, str]:
|
||||
continue
|
||||
|
||||
var_name = skill_name.split("/")[-1]
|
||||
skill_content[var_name] = _FRONTMATTER_PATTERN.sub("", content).lstrip()
|
||||
_, skill_body = _parse_skill_content(content, file_path)
|
||||
skill_content[var_name] = skill_body
|
||||
logger.debug("Loaded skill: %s -> %s", skill_name, var_name)
|
||||
_track_skill_loaded(var_name, file_path)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ GCP misconfigurations expose project data, service account keys, and lateral mov
|
||||
|
||||
**Storage & Data**
|
||||
- Cloud Storage (GCS) buckets and objects
|
||||
- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase_firestore` skill)
|
||||
- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase` skill)
|
||||
- Secret Manager, Cloud KMS keys
|
||||
|
||||
**Compute**
|
||||
|
||||
+63
-11
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: firebase-firestore
|
||||
description: Firebase/Firestore security testing covering security rules, Cloud Functions, and client-side trust issues
|
||||
name: firebase
|
||||
description: Firebase security testing covering Firestore, Storage rules, Realtime Database, Auth, Functions, and client-side trust issues
|
||||
---
|
||||
|
||||
# Firebase / Firestore
|
||||
# Firebase
|
||||
|
||||
Security testing for Firebase applications. Focus on Firestore/Realtime Database rules, Cloud Storage exposure, callable/onRequest Functions trusting client input, and incorrect ID token validation.
|
||||
|
||||
@@ -30,7 +30,17 @@ Security testing for Firebase applications. Focus on Firestore/Realtime Database
|
||||
**Endpoints**
|
||||
- Firestore REST: `https://firestore.googleapis.com/v1/projects/<project>/databases/(default)/documents/<path>`
|
||||
- Realtime DB: `https://<project>.firebaseio.com/.json`
|
||||
- Storage REST: `https://storage.googleapis.com/storage/v1/b/<bucket>`
|
||||
- GCS JSON API: `https://storage.googleapis.com/storage/v1/b/<bucket>`
|
||||
- Firebase Storage rules API: `https://firebasestorage.googleapis.com/v0/b/<bucket>/o`
|
||||
|
||||
Cloud Storage has two front doors with different authorization engines:
|
||||
|
||||
| Front door | Authorization engine |
|
||||
| --- | --- |
|
||||
| `storage.googleapis.com/<bucket>/<object>` and `/storage/v1/b/<bucket>` | GCS IAM and per-object ACLs |
|
||||
| `firebasestorage.googleapis.com/v0/b/<bucket>/o` | Firebase Storage Security Rules |
|
||||
|
||||
A `403` from a GCS URL does not prove that Firebase Storage rules deny access. Always test both doors.
|
||||
|
||||
**Auth**
|
||||
- Google-signed ID tokens (iss: `accounts.google.com` or `securetoken.google.com/<project>`)
|
||||
@@ -117,9 +127,43 @@ exists(/databases/(default)/documents/orgs/$(org)/members/$(request.auth.uid))
|
||||
- Public reads on sensitive buckets/paths
|
||||
- Signed URLs with long TTL, no content-disposition controls, replayable across tenants
|
||||
- List operations exposed: `/o?prefix=` enumerates object keys
|
||||
- Firebase Storage rules allowing unauthenticated or overly broad reads and writes
|
||||
|
||||
**Firebase Storage rules checks**
|
||||
|
||||
Probe the rules door separately from GCS IAM and ACLs:
|
||||
|
||||
1. Unauthenticated list: `GET https://firebasestorage.googleapis.com/v0/b/<bucket>/o?prefix=<known-prefix>`
|
||||
2. Unauthenticated read of a known object path
|
||||
3. Unauthenticated write/upload to a uniquely named test object
|
||||
4. Repeat list, read, and write as an anonymous-auth principal when anonymous sign-in is enabled
|
||||
5. Repeat the same matrix as a low-privilege authenticated user
|
||||
|
||||
Write access is as important as read access and is routinely missed. Record status, response body, and object existence after each attempt; clean up only test objects that the test principal created.
|
||||
|
||||
Review rules source when present and flag:
|
||||
|
||||
- `allow read, write: if request.time < timestamp.date(...)` — the common console test-mode time gate
|
||||
- `{allPaths=**}` catch-alls
|
||||
- `request.auth != null` as the sole authorization gate
|
||||
- Claim-presence checks such as `request.auth.token.roles.size() > 0` without role or tenant validation
|
||||
|
||||
Storage rules use OR-across-matches semantics: a later permissive match can reopen a path that an earlier match denied. Review every matching path, not only the most specific-looking deny.
|
||||
|
||||
**Bucket discovery**
|
||||
|
||||
- Extract `storageBucket` from `firebase.apps[0].options` and `NEXT_PUBLIC_FIREBASE_*` values in JavaScript bundles and source.
|
||||
- Check `<project>.appspot.com` and `<project>.firebasestorage.app` bucket conventions.
|
||||
|
||||
**ACL and IAM checks are separate**
|
||||
|
||||
- Sweep object ACLs for `allUsers` and `allAuthenticatedUsers`, including objects made public by Admin SDK `makePublic()` or writers using `public: true`. Per-object public ACLs persist after Firebase rules are tightened and can remain on older prefixes.
|
||||
- Check bucket IAM for `allUsers` and `allAuthenticatedUsers`.
|
||||
- Check whether Uniform Bucket-Level Access is disabled; legacy object ACLs matter when it is off.
|
||||
- Account for CDN caching of previously public objects; cache-bust when verifying a revocation.
|
||||
|
||||
**Tests**
|
||||
- GET gs:// paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment`
|
||||
- GET GCS object paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment`
|
||||
- Generate and reuse signed URLs across accounts and paths; try case/URL-encoding variants
|
||||
- Upload HTML/SVG and verify `X-Content-Type-Options: nosniff`; check for script execution
|
||||
|
||||
@@ -189,12 +233,19 @@ Apps often implement multi-tenant data models (`orgs/<orgId>/...`). Bind tenant
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
1. **Extract config** - Get project config from client bundle
|
||||
2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, admin
|
||||
1. **Extract config** - Get project and storage bucket config from client bundles and source
|
||||
2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, and admin where authorized
|
||||
3. **Build matrix** - Resource × Action × Principal across Firestore/Realtime/Storage/Functions
|
||||
4. **SDK vs REST** - Exercise every action via both to detect parity gaps
|
||||
5. **Seed IDs** - Start from list/query paths to gather document IDs
|
||||
6. **Cross-principal** - Swap document paths, tenants, and user IDs across principals
|
||||
4. **Exercise both Storage doors** - Test Firebase Storage rules endpoints separately from GCS IAM/ACL URLs
|
||||
5. **SDK vs REST** - Exercise every action via both to detect parity gaps
|
||||
6. **Seed IDs** - Start from list/query paths to gather document and object paths
|
||||
7. **Cross-principal** - Swap document paths, tenants, and user IDs across principals
|
||||
|
||||
## Whitebox Rules Review
|
||||
|
||||
- Inspect `firebase.json`, `.firebaserc`, deployment scripts, CI configuration, and infrastructure code for `storage.rules` / `firestore.rules` declarations.
|
||||
- If `firebase.json` has no `storage` or `firestore` block, or the referenced rules file is absent from the tree, treat the live rules as unmanaged and force the live probe matrix. Absence of rules IaC is itself a finding; never conclude that there is nothing to review.
|
||||
- Correlate configured rule files with deployed project and bucket identifiers. A source rule file for a different project does not establish live protection.
|
||||
|
||||
## Tooling
|
||||
|
||||
@@ -206,6 +257,7 @@ Apps often implement multi-tenant data models (`orgs/<orgId>/...`). Bind tenant
|
||||
## Validation Requirements
|
||||
|
||||
- Owner vs non-owner Firestore queries showing unauthorized access or metadata leak
|
||||
- Cloud Storage read/write beyond intended scope (public object, signed URL reuse, list exposure)
|
||||
- Firebase Storage unauthenticated, anonymous, or low-privilege read/list/write beyond intended scope, with minimal reproducible requests and observed deltas
|
||||
- GCS object ACL or bucket IAM access beyond intended scope, including public object persistence after rules changes
|
||||
- Function accepting forged/foreign identity (wrong `aud`/`iss`) or trusting client `uid`/`orgId`
|
||||
- Minimal reproducible requests with roles/tokens used and observed deltas
|
||||
@@ -1,8 +1,10 @@
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import strix.skills as skills_mod
|
||||
from strix.agents.prompt import render_system_prompt
|
||||
from strix.skills import (
|
||||
get_all_skill_names,
|
||||
get_available_skills,
|
||||
@@ -12,10 +14,11 @@ from strix.skills import (
|
||||
skill_search_dirs,
|
||||
validate_requested_skills,
|
||||
)
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_extra_dirs() -> None:
|
||||
def _clear_extra_dirs() -> Iterator[None]:
|
||||
original = list(skills_mod._EXTRA_SKILL_DIRS)
|
||||
skills_mod._EXTRA_SKILL_DIRS.clear()
|
||||
try:
|
||||
@@ -37,9 +40,11 @@ def _write_root_skill(root: Path, name: str, body: str) -> None:
|
||||
|
||||
def test_no_registration_leaves_builtin_only() -> None:
|
||||
assert registered_skill_dirs() == ()
|
||||
builtin = skills_mod.get_strix_resource_path("skills")
|
||||
builtin = get_strix_resource_path("skills")
|
||||
assert skill_search_dirs() == (builtin,)
|
||||
assert {"nmap", "subfinder"}.issubset(get_available_skills()["tooling"])
|
||||
assert {"nmap", "subfinder"}.issubset(
|
||||
{skill["name"] for skill in get_available_skills()["tooling"]}
|
||||
)
|
||||
|
||||
|
||||
def test_register_is_idempotent_and_ordered(tmp_path: Path) -> None:
|
||||
@@ -61,16 +66,125 @@ def test_registered_dir_adds_new_skill(tmp_path: Path) -> None:
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
assert "widget" in get_all_skill_names()
|
||||
assert get_available_skills()["extra"] == ["widget"]
|
||||
assert get_available_skills()["extra"] == [{"name": "widget", "description": ""}]
|
||||
assert load_skills(["widget"]) == {"widget": "widget body"}
|
||||
|
||||
|
||||
def test_available_skill_includes_frontmatter_description(tmp_path: Path) -> None:
|
||||
_write_skill(
|
||||
tmp_path,
|
||||
"extra",
|
||||
"widget",
|
||||
"---\nname: widget\ndescription: Useful widget guidance\n---\nwidget body",
|
||||
)
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
assert get_available_skills()["extra"] == [
|
||||
{"name": "widget", "description": "Useful widget guidance"}
|
||||
]
|
||||
|
||||
|
||||
def test_available_skill_supports_colon_in_description(tmp_path: Path) -> None:
|
||||
_write_skill(
|
||||
tmp_path,
|
||||
"extra",
|
||||
"widget",
|
||||
'---\nname: widget\ndescription: "Useful widget: handles YAML"\n---\nwidget body',
|
||||
)
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
assert get_available_skills()["extra"] == [
|
||||
{"name": "widget", "description": "Useful widget: handles YAML"}
|
||||
]
|
||||
|
||||
|
||||
def test_available_skill_normalizes_quoted_description(tmp_path: Path) -> None:
|
||||
_write_skill(
|
||||
tmp_path,
|
||||
"extra",
|
||||
"widget",
|
||||
'---\nname: widget\ndescription: "Useful: widget guidance"\n---\nwidget body',
|
||||
)
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
assert get_available_skills()["extra"] == [
|
||||
{"name": "widget", "description": "Useful: widget guidance"}
|
||||
]
|
||||
|
||||
|
||||
def test_available_skill_normalizes_multiline_descriptions(tmp_path: Path) -> None:
|
||||
_write_skill(
|
||||
tmp_path,
|
||||
"extra",
|
||||
"block",
|
||||
"---\nname: block\n\ndescription: |\n"
|
||||
" First paragraph\n\n Second paragraph\n\n---\nblock body",
|
||||
)
|
||||
_write_skill(
|
||||
tmp_path,
|
||||
"extra",
|
||||
"plain",
|
||||
"---\nname: plain\n\ndescription: First line\n Second line\n\n---\nplain body",
|
||||
)
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
available = {skill["name"]: skill["description"] for skill in get_available_skills()["extra"]}
|
||||
assert available == {
|
||||
"block": "First paragraph Second paragraph",
|
||||
"plain": "First line Second line",
|
||||
}
|
||||
|
||||
|
||||
def test_available_skill_supports_block_scalar_trailing_comment(tmp_path: Path) -> None:
|
||||
_write_skill(
|
||||
tmp_path,
|
||||
"extra",
|
||||
"commented",
|
||||
"---\nname: commented\ndescription: | # paragraph\n"
|
||||
" First line\n Second line\n---\ncommented body",
|
||||
)
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
assert get_available_skills()["extra"] == [
|
||||
{"name": "commented", "description": "First line Second line"}
|
||||
]
|
||||
|
||||
|
||||
def test_malformed_frontmatter_keeps_skill_body(tmp_path: Path) -> None:
|
||||
_write_skill(
|
||||
tmp_path,
|
||||
"extra",
|
||||
"broken",
|
||||
"---\nname: [broken\ndescription: should be empty\n---\nbroken body",
|
||||
)
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
assert get_available_skills()["extra"] == [{"name": "broken", "description": ""}]
|
||||
assert load_skills(["extra/broken"]) == {"broken": "broken body"}
|
||||
|
||||
|
||||
def test_system_prompt_renders_skill_descriptions() -> None:
|
||||
prompt = render_system_prompt(scan_mode="quick", is_root=True)
|
||||
|
||||
assert "- technologies/firebase: Firebase security testing covering" in prompt
|
||||
|
||||
|
||||
def test_system_prompt_omits_empty_skill_description(tmp_path: Path) -> None:
|
||||
_write_skill(tmp_path, "extra", "widget", "---\nname: widget\ndescription:\n---\nwidget body")
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
prompt = render_system_prompt(scan_mode="quick", is_root=True)
|
||||
|
||||
assert "- extra/widget\n" in prompt
|
||||
assert "- extra/widget: " not in prompt
|
||||
|
||||
|
||||
def test_registered_root_skill_is_discoverable_and_valid(tmp_path: Path) -> None:
|
||||
_write_root_skill(tmp_path, "widget", "widget body")
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
assert "widget" in get_all_skill_names()
|
||||
assert get_available_skills()["root"] == ["widget"]
|
||||
assert get_available_skills()["root"] == [{"name": "widget", "description": ""}]
|
||||
assert validate_requested_skills(["widget"]) is None
|
||||
assert validate_requested_skills(["root/widget"]) is None
|
||||
assert load_skills(["widget"]) == {"widget": "widget body"}
|
||||
@@ -83,8 +197,8 @@ def test_ambiguous_bare_skill_requires_qualified_name(tmp_path: Path) -> None:
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
assert "widget" in get_all_skill_names()
|
||||
assert get_available_skills()["alpha"] == ["widget"]
|
||||
assert get_available_skills()["beta"] == ["widget"]
|
||||
assert get_available_skills()["alpha"] == [{"name": "widget", "description": ""}]
|
||||
assert get_available_skills()["beta"] == [{"name": "widget", "description": ""}]
|
||||
assert validate_requested_skills(["alpha/widget"]) is None
|
||||
assert validate_requested_skills(["beta/widget"]) is None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user