From 5c4ed74252be850df28e857947c27ae82d2c2e69 Mon Sep 17 00:00:00 2001 From: karthickmk2001 Date: Wed, 11 Nov 2026 21:18:09 +0000 Subject: [PATCH 1/2] feat(skills): custom skills from user folders, config paths and --skill-file (#79) Load skills from ~/.secfoo/skills, [skills] paths, SECFOO_SKILLS_PATH and secfoo run --skill-file -- never from the scan target. Custom skills may reuse a built-in's output contract and findings lifecycle (output: sast) without ever closing the built-in scan's findings. Malformed or clashing user skills are skipped with a warning naming the files; built-ins still fail hard; user folders reload on mtime change. Adds secfoo skills list/new/validate/show, runs.skill_version/skill_source/skill_sha256 with a migration, a Custom skills sidebar group and picker badge, docs/SKILLS.md, examples/skills/ and a CI validation step. Closes #79 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 + docs/SKILLS.md | 119 ++++++ examples/skills/django-sast.md | 34 ++ examples/skills/graphql-api-review.md | 34 ++ src/secfoo/cli.py | 146 +++++++- src/secfoo/runner.py | 62 ++-- src/secfoo/settings.py | 10 + src/secfoo/skills/loader.py | 232 +++++++++++- src/secfoo/skills/renderer.py | 6 +- src/secfoo/storage/models.py | 4 + src/secfoo/storage/repository.py | 13 +- src/secfoo/storage/schema.sql | 8 +- src/secfoo/web/static/css/dashboard.css | 2 + src/secfoo/web/templates/assessments/run.html | 2 +- src/secfoo/web/templates/base.html | 16 + src/secfoo/web/templates/run_detail.html | 3 + src/secfoo/web/templating.py | 31 +- tests/conftest.py | 16 + tests/test_custom_skills.py | 346 ++++++++++++++++++ 19 files changed, 1049 insertions(+), 39 deletions(-) create mode 100644 docs/SKILLS.md create mode 100644 examples/skills/django-sast.md create mode 100644 examples/skills/graphql-api-review.md create mode 100644 tests/test_custom_skills.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c672c4a..bcac960 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,7 @@ jobs: - name: Test (secfoo core) run: pytest tests + + - name: Validate example custom skills + # Same check a team's own skills repo would run (docs/SKILLS.md). + run: secfoo skills validate examples/skills diff --git a/docs/SKILLS.md b/docs/SKILLS.md new file mode 100644 index 0000000..b14d5e3 --- /dev/null +++ b/docs/SKILLS.md @@ -0,0 +1,119 @@ +# Writing your own skills + +A **skill** is one kind of security review secfoo can run: SAST, threat +modeling, SCA reachability, and so on. You can add your own, such as a GraphQL +API review, a PCI DSS scoping pass, or a check against your internal coding +standard, by dropping a Markdown file in a folder. It then shows up in +`secfoo run --skill`, `--help`, the dashboard and assessments. You don't change +any code or rebuild anything. + +```bash +secfoo skills new graphql-review # scaffold ~/.secfoo/skills/graphql-review.md +$EDITOR ~/.secfoo/skills/graphql-review.md +secfoo skills validate # check every custom skill +secfoo skills show graphql-review --target . # preview the exact prompt +secfoo run --skill graphql-review --target . +``` + +Two complete examples are in [`examples/skills/`](../examples/skills/). + +## Where skills are loaded from + +All of these are merged: + +1. **Built-ins** shipped with secfoo. +2. **Your folder:** `$SECFOO_HOME/skills/*.md` (default `~/.secfoo/skills/`). +3. **Extra folders** from `~/.secfoo/config.toml`, for example a team's shared + skills repo checked out locally: + ```toml + [skills] + paths = ["~/work/appsec-skills", "/opt/company/secfoo-skills"] + ``` +4. **`SECFOO_SKILLS_PATH`:** extra folders separated by `:` (`;` on Windows), + handy in CI. +5. **One-off:** `secfoo run --skill-file ./my-skill.md --target .` runs a file + without installing it. + +secfoo **never** loads skills from the target it is scanning. A cloned repo +can't add instructions by shipping a `.secfoo/skills/` folder. + +`secfoo skills list` shows every skill with its source and output type. +`secfoo serve` picks up new or edited skill files without a restart. + +## File format + +```markdown +--- +id: graphql-review +name: GraphQL API Security Review +short_name: GraphQL API +description: Reviews a GraphQL API for authorization gaps and query-cost abuse. +version: 1 +output: generic +category: API +--- + +Your instructions to the reviewer go here... +``` + +| Field | Required | Meaning | +|---|---|---| +| `id` | yes | Lowercase kebab-case. Must not match a built-in or another custom skill. | +| `name` | yes | Shown in reports and the dashboard. | +| `description` | yes | One line, shown in the skill picker. | +| `version` | yes | An integer. Bump it when you change the instructions. | +| `short_name` | no | Compact label for the sidebar. | +| `output` | no | `generic` (default) or the id of a built-in skill whose report format and findings dashboard to reuse. | +| `category` | no | Groups custom skills in the sidebar (e.g. `Compliance`). | + +Lines starting with `#` in the frontmatter are comments. + +### `output`: where results go + +- **`generic`:** the report uses secfoo's generic format, with findings under + `### [SEVERITY] F1: title` headings. The skill gets its own activity page, + run list and severity counts. +- **A built-in id, such as `sast`, `sca-reachability` or `secret-scanning`:** + the report uses that skill's exact format, and its findings land in that + skill's dashboard and open/closed lifecycle. A custom "SAST for our + framework" skill (see `examples/skills/django-sast.md`) then shows up next to + the built-in SAST findings. A custom run only **adds and updates** findings. + It never closes findings the built-in scan recorded, because it only covered + what your instructions asked about. + +## What secfoo adds around your instructions + +The skill body holds only your instructions. secfoo always wraps them with: + +- its safety preamble (treat the target as untrusted, never execute it); +- the target and exclusion sections; +- depth guidance (`--depth quick` / `standard`); +- the output contract for the skill's `output` type. + +A custom skill can't remove any of these, so its reports always parse and its +severity counts and CI gates (`--fail-on`) work like a built-in's. Run +`secfoo skills show ` to see the full prompt. + +## Validation + +- A malformed custom skill, or one whose `id` clashes with another, is + **skipped with a one-line warning** naming the files. Every other command keeps + working. Built-in skills fail loudly instead. +- `secfoo skills validate [PATH...]` checks files or folders and exits `1` on + any problem. Run it in CI on a shared skills repo: + ```yaml + - run: pip install secfoo && secfoo skills validate . + ``` + +## Traceability + +Custom skills change over time, so every run records which definition produced +it: the skill's `version`, its source (`builtin` / `user`) and a SHA-256 of the +definition file. These appear on the run's page in the dashboard. + +## Sharing skills across a team + +Keep the skills in a git repo, one `.md` file per skill. Run +`secfoo skills validate .` in its CI, and have each person add the checkout to +`[skills] paths`. Updating the repo updates everyone's skills. Bump `version` +when you change a skill's instructions, so runs show which wording they used. diff --git a/examples/skills/django-sast.md b/examples/skills/django-sast.md new file mode 100644 index 0000000..5885305 --- /dev/null +++ b/examples/skills/django-sast.md @@ -0,0 +1,34 @@ +--- +id: django-sast +name: SAST — Django Application Review +short_name: SAST (Django) +description: Django-specific static review whose findings join the SAST dashboard. +version: 1 +# Reuse the built-in SAST report format, so findings land in the SAST +# dashboard and its open/closed lifecycle. +output: sast +category: Frameworks +--- + +Perform a static security review of this Django application, focusing on the +mistakes that are specific to Django and that a generic SAST pass tends to miss: + +- **Raw SQL.** `raw()`, `extra()`, `RawSQL`, and `cursor.execute()` built with + string formatting or f-strings instead of parameters. +- **Template autoescaping.** `|safe`, `mark_safe`, `{% autoescape off %}` and + `format_html` misuse on user-controlled values. +- **CSRF.** `@csrf_exempt` on views that change state, and DRF views using + `SessionAuthentication` without CSRF enforcement. +- **Object-level authorization.** Views and DRF viewsets that fetch + `Model.objects.get(pk=...)` from the URL without scoping to the requesting + user, and `get_queryset()` methods that don't filter by owner or tenant. +- **Settings.** `DEBUG = True`, a hard-coded `SECRET_KEY`, permissive + `ALLOWED_HOSTS`, missing `SECURE_*` and session-cookie flags in production + settings. +- **Mass assignment.** `ModelForm` / `ModelSerializer` with `fields = "__all__"` + or `exclude` on models that have privilege or ownership fields. +- **Unsafe deserialization and file handling.** `pickle`, `yaml.load`, and + uploads saved under user-controlled names or served from `MEDIA_ROOT` + without validation. + +Report each finding with its taint path from the request to the sink. diff --git a/examples/skills/graphql-api-review.md b/examples/skills/graphql-api-review.md new file mode 100644 index 0000000..3120de3 --- /dev/null +++ b/examples/skills/graphql-api-review.md @@ -0,0 +1,34 @@ +--- +id: graphql-api-review +name: GraphQL API Security Review +short_name: GraphQL API +description: Reviews a GraphQL API for authorization gaps, query-cost abuse and schema exposure. +version: 1 +output: generic +category: API +--- + +Review the GraphQL API in this codebase as an attacker with a valid but +low-privilege account would approach it. Find the schema (SDL files, code-first +type definitions, or resolver maps) and the server setup first, then work +through these risks: + +- **Authorization in resolvers.** Check every query, mutation and field + resolver that returns or changes another user's data. Authorization done only + at the HTTP route or gateway is not enough: nested fields + (`user { orders { paymentMethod } }`) are reachable from any parent type. + Flag object-level and field-level checks that are missing or inconsistent. +- **Query cost and depth.** Look for query depth limits, complexity or cost + analysis, pagination caps (`first`/`last` maximums), and batching or alias + limits. Unbounded lists and recursive types without limits are a + denial-of-service finding. +- **Introspection and errors.** Is introspection enabled in production + configuration? Do error responses include stack traces, SQL, or field + suggestions that leak the schema? +- **Injection through arguments.** Trace resolver arguments into SQL, NoSQL, + shell, file paths or downstream HTTP calls without validation. +- **Persisted queries and CSRF.** If GET queries or simple content types are + accepted, check whether mutations can be triggered cross-site. + +For each finding, name the resolver or type and the file and line where the +check is missing, and give the concrete fix (the guard to add, and where). diff --git a/src/secfoo/cli.py b/src/secfoo/cli.py index 407ed46..38cdb59 100644 --- a/src/secfoo/cli.py +++ b/src/secfoo/cli.py @@ -26,7 +26,7 @@ from secfoo.report.sarif import findings_to_sarif from secfoo.runner import _project_display_name, already_scanned_skills, execute_runs from secfoo.settings import CONFIG_PATH, ConfigError, load_config -from secfoo.skills.loader import load_all_skills +from secfoo.skills.loader import SkillLoadError, load_all_skills, register_skill_file from secfoo.skills.renderer import merge_excludes as _merge_excludes from secfoo.storage.repository import RunRepository from secfoo.targets.resolver import TargetResolutionError, resolve_target @@ -54,6 +54,8 @@ app.add_typer(accept_app, name="accept") app.add_typer(cloud_app, name="cloud") app.add_typer(vendor_app, name="vendor") +skills_app = typer.Typer(name="skills", help="List, create, validate and preview skills, including your own.") +app.add_typer(skills_app, name="skills") console = Console() err_console = Console(stderr=True) @@ -191,8 +193,12 @@ def _resolve_project_id(repo: RunRepository, target: str | None) -> int: @app.command() def run( skill: list[SkillId] = typer.Option( - ..., "--skill", "-s", help="Security skill to run. Repeatable." + [], "--skill", "-s", help="Security skill to run. Repeatable. Includes custom skills (`secfoo skills list`)." ), + skill_file: Annotated[list[Path] | None, typer.Option( + "--skill-file", + help="Run a skill straight from a Markdown definition file, without installing it. Repeatable.", + )] = None, target: Optional[str] = typer.Option( None, "--target", "-t", help="GitHub URL or local directory. Defaults to the current directory." ), @@ -307,6 +313,17 @@ def run( f"[yellow]Warning:[/] {url!r} doesn't look like a URL — passing it through anyway." ) + skill_ids = [s.value for s in skill] + for path in skill_file or []: + try: + skill_ids.append(register_skill_file(path).id) + except (SkillLoadError, OSError, UnicodeDecodeError) as exc: + err_console.print(f"[red]Error:[/] {exc}") + raise typer.Exit(code=1) from None + if not skill_ids: + err_console.print("[red]Error:[/] pass at least one --skill or --skill-file.") + raise typer.Exit(code=2) + # No --assessment means execute_runs() will auto-create one for this run. # On a real terminal, ask for the two fields that make that case file # actually trackable later instead of silently falling back to a @@ -357,7 +374,7 @@ def run( # dashboard and CI also call with no terminal to answer. if not rescan and _is_interactive() and not json_output: try: - head, repeats = already_scanned_skills(skill_ids=[s.value for s in skill], target=target) + head, repeats = already_scanned_skills(skill_ids=skill_ids, target=target) except TargetResolutionError: head, repeats = None, [] # execute_runs reports the bad target below if head and repeats and not Confirm.ask( @@ -365,8 +382,8 @@ def run( default=False, console=console, ): - skill = [s for s in skill if s.value not in repeats] - if not skill: + skill_ids = [s for s in skill_ids if s not in repeats] + if not skill_ids: console.print("Nothing new to scan. Use --rescan to scan this commit again.") raise typer.Exit(code=0) @@ -398,7 +415,7 @@ def handle_complete(skill_name: str, result_status: str) -> None: with progress: try: outcomes = execute_runs( - skill_ids=[s.value for s in skill], + skill_ids=skill_ids, target=target, confluence_urls=confluence, agent_id=agent.value, @@ -1609,6 +1626,123 @@ def runtime_watchlist( f"{len(entries)} watchlist entr{'y' if len(entries) == 1 else 'ies'}: {symbol_entries} with symbols, " f"{len(entries) - symbol_entries} at package granularity" + (f". Wrote {output}." if output else ".") ) +SKILL_TEMPLATE = """\ +--- +# Lowercase kebab-case, unique across built-in and custom skills. +id: {skill_id} +name: {name} +short_name: {short_name} +description: One sentence on what this review checks. +version: 1 +# generic (own report) or a built-in id whose report format and findings +# dashboard to reuse: sast, sca-reachability, secret-scanning, ... +output: generic +# Optional sidebar group, e.g. Compliance. +category: +--- + +Describe the review you want, as you would brief a senior reviewer. Name the +specific risks to look for, where they tend to hide in this kind of codebase, +and what a good finding looks like. + +secfoo adds its own safety rules, the target, depth guidance and the required +report format around these instructions, so don't restate them here. +""" + + +def _skill_source_label(skill) -> str: + return {"builtin": "built-in", "user": "user", "path": "path", "file": "file"}.get(skill.source, skill.source) + + +@skills_app.command(name="list") +def skills_list() -> None: + """Every skill secfoo can run: built-in and custom, with where each one comes from.""" + from secfoo.skills.loader import load_problems, user_skill_dirs + + table = Table(show_header=True, header_style="bold") + for column in ("id", "name", "version", "source", "output"): + table.add_column(column) + for skill in sorted(load_all_skills().values(), key=lambda s: (not s.is_builtin, s.id)): + source = _skill_source_label(skill) + if not skill.is_builtin and skill.path: + source = f"{source}: {skill.path}" + table.add_row(skill.id, skill.name, str(skill.version), source, skill.output_type) + console.print(table) + folders = ", ".join(str(folder) for folder, _ in user_skill_dirs()) + console.print(f"[dim]Custom skills are read from: {folders}[/]") + for problem in load_problems(): + err_console.print(f"[yellow]Warning:[/] {problem}") + + +@skills_app.command(name="new") +def skills_new( + skill_id: Annotated[str, typer.Argument(help="Id for the new skill, e.g. graphql-review.")], + force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False, +) -> None: + """Scaffold ~/.secfoo/skills/.md from a commented template.""" + from secfoo.config import STORE_DIR + from secfoo.skills.loader import ID_RE, builtin_skills + + if not ID_RE.match(skill_id): + err_console.print(f"[red]Error:[/] {skill_id!r} must be lowercase kebab-case, e.g. graphql-review.") + raise typer.Exit(code=1) + if skill_id in builtin_skills(): + err_console.print(f"[red]Error:[/] {skill_id!r} is a built-in skill id; pick another.") + raise typer.Exit(code=1) + path = STORE_DIR / "skills" / f"{skill_id}.md" + if path.exists() and not force: + err_console.print(f"[red]Error:[/] {path} already exists (use --force to overwrite).") + raise typer.Exit(code=1) + path.parent.mkdir(parents=True, exist_ok=True) + words = skill_id.replace("-", " ") + path.write_text( + SKILL_TEMPLATE.format(skill_id=skill_id, name=words.title(), short_name=words.title()[:24]), + encoding="utf-8", + ) + console.print(f"Created {path}. Edit it, then `secfoo skills validate {path}` and `secfoo run --skill {skill_id}`.") + + +@skills_app.command(name="validate") +def skills_validate( + paths: Annotated[ + list[Path] | None, + typer.Argument(help="Skill files or folders. Defaults to every configured custom skill folder."), + ] = None, +) -> None: + """Check custom skill files: frontmatter, ids, output type. Exits 1 on any problem (for CI).""" + from secfoo.skills.loader import user_skill_dirs, validate_files + + targets = list(paths) if paths else [folder for folder, _ in user_skill_dirs() if folder.is_dir()] + valid, problems = validate_files(targets) + for skill in valid: + console.print(f"[green]ok[/] {skill.id} ({skill.path})") + for problem in problems: + err_console.print(f"[red]error[/] {problem}") + console.print(f"{len(valid)} valid, {len(problems)} problem(s).") + if problems: + raise typer.Exit(code=1) + + +@skills_app.command(name="show") +def skills_show( + skill_id: Annotated[str, typer.Argument(help="Skill id (see `secfoo skills list`).")], + target: Annotated[ + Path | None, typer.Option("--target", help="Local folder to render the prompt for. Defaults to cwd.") + ] = None, + depth: Annotated[DepthId, typer.Option("--depth", "-d")] = DepthId.QUICK, +) -> None: + """Print the full prompt the agent would receive for a skill, without running it.""" + from secfoo.skills.loader import load_skill + from secfoo.skills.renderer import TargetContext, render_prompt + + try: + skill = load_skill(skill_id) + except SkillLoadError as exc: + err_console.print(f"[red]Error:[/] {exc}") + raise typer.Exit(code=1) from None + workdir = (target or Path.cwd()).resolve() + context = TargetContext(kind="local", local_path=workdir, display_source=str(workdir)) + sys.stdout.write(render_prompt(skill, context, depth=depth.value) + "\n") MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js" diff --git a/src/secfoo/runner.py b/src/secfoo/runner.py index ba76a0b..9a84481 100644 --- a/src/secfoo/runner.py +++ b/src/secfoo/runner.py @@ -189,6 +189,7 @@ def update_sast_findings( workdir: Path | None = None, stack: Stack | None = None, scan_scope: list[str] | None = None, + close_stale: bool = True, ) -> None: """Matches this run's Findings Register against the project's currently-open sast_findings rows by fingerprint (report/sast.py), @@ -252,13 +253,14 @@ def update_sast_findings( # scan_scope (Memory Bank incremental scan): only findings in files this # run actually looked at can be closed; everything else stays as it was. - repo.close_stale_sast_findings( - project_id=project_id, - run_id=run_id, - seen_fingerprints=seen_fingerprints, - closed_at=_closed_now(), - only_files=scan_scope, - ) + if close_stale: + repo.close_stale_sast_findings( + project_id=project_id, + run_id=run_id, + seen_fingerprints=seen_fingerprints, + closed_at=_closed_now(), + only_files=scan_scope, + ) def update_sca_findings( @@ -269,6 +271,7 @@ def update_sca_findings( report_text: str, stack: Stack | None = None, scan_scope: list[str] | None = None, + close_stale: bool = True, ) -> None: """update_sast_findings' lifecycle for the SCA Risk Register.""" seen_fingerprints = [] @@ -292,10 +295,11 @@ def update_sca_findings( **_stack_fields(stack, language_for_ecosystem(row.get("ecosystem"))), ) seen_fingerprints.append(row["fingerprint"]) - repo.close_stale_findings( - SCA_SKILL_ID, project_id=project_id, run_id=run_id, seen_fingerprints=seen_fingerprints, - closed_at=_closed_now(), only_files=scan_scope, - ) + if close_stale: + repo.close_stale_findings( + SCA_SKILL_ID, project_id=project_id, run_id=run_id, seen_fingerprints=seen_fingerprints, + closed_at=_closed_now(), only_files=scan_scope, + ) def update_secret_findings( @@ -307,6 +311,7 @@ def update_secret_findings( workdir: Path | None = None, stack: Stack | None = None, scan_scope: list[str] | None = None, + close_stale: bool = True, ) -> None: """update_sast_findings' lifecycle for the Secret Scanning register.""" seen_fingerprints = [] @@ -330,10 +335,11 @@ def update_secret_findings( **_stack_fields(stack, language_for_path(row["location_file"])), ) seen_fingerprints.append(row["fingerprint"]) - repo.close_stale_findings( - SECRET_SKILL_ID, project_id=project_id, run_id=run_id, seen_fingerprints=seen_fingerprints, - closed_at=_closed_now(), only_files=scan_scope, - ) + if close_stale: + repo.close_stale_findings( + SECRET_SKILL_ID, project_id=project_id, run_id=run_id, seen_fingerprints=seen_fingerprints, + closed_at=_closed_now(), only_files=scan_scope, + ) def _mark_run_crashed(repo: RunRepository, run_uuid: str, *, duration_seconds: float) -> None: @@ -367,25 +373,32 @@ def update_structured_findings( workdir: Path | None = None, stack: Stack | None = None, scan_scope: list[str] | None = None, + close_stale: bool = True, ) -> None: """Dispatches one successful run's report to its skill's findings lifecycle (a no-op for skills without one). The single entry point both callers use -- _run_single_skill after a local run, and portal/routes/ingest.py after a pushed one (no workdir there, so no - region hashes or framework detection).""" + region hashes or framework detection). + + `skill_id` is the OUTPUT type: a custom skill with `output: sast` (#79) + passes "sast" here, with close_stale=False -- its report covers only + what that custom review looked at, so it must never close findings + the built-in scan recorded.""" if skill_id == SAST_SKILL_ID: update_sast_findings( repo, project_id=project_id, run_id=run_id, report_text=report_text, workdir=workdir, stack=stack, - scan_scope=scan_scope, + scan_scope=scan_scope, close_stale=close_stale, ) elif skill_id == SCA_SKILL_ID: update_sca_findings( - repo, project_id=project_id, run_id=run_id, report_text=report_text, stack=stack, scan_scope=scan_scope + repo, project_id=project_id, run_id=run_id, report_text=report_text, stack=stack, scan_scope=scan_scope, + close_stale=close_stale, ) elif skill_id == SECRET_SKILL_ID: update_secret_findings( repo, project_id=project_id, run_id=run_id, report_text=report_text, workdir=workdir, stack=stack, - scan_scope=scan_scope, + scan_scope=scan_scope, close_stale=close_stale, ) @@ -438,6 +451,9 @@ def _run_single_skill( confluence_urls=confluence_urls, assessment_id=assessment_id, model=model, + skill_version=skill.version, + skill_source="builtin" if skill.is_builtin else "user", + skill_sha256=skill.sha256 or None, ) started = time.monotonic() @@ -503,20 +519,22 @@ def _run_single_skill( if result.status == "success": _try_cloud_sync(repo, run_uuid) - if skill.id == SCA_SKILL_ID: + output_type = skill.output_type # #79: a custom skill may reuse a built-in's lifecycle + if output_type == SCA_SKILL_ID: _try_osv_enrich(repo, report_text) - if skill.id in STRUCTURED_FINDINGS_SKILLS: + if output_type in STRUCTURED_FINDINGS_SKILLS: try: completed_run = repo.get_run(run_uuid) update_structured_findings( repo, - skill_id=skill.id, + skill_id=output_type, project_id=project_id, run_id=completed_run.id, report_text=report_text, workdir=target_ctx.local_path, stack=stack, scan_scope=result.scan_scope, + close_stale=skill.is_builtin, ) except Exception: logger.exception("Failed to update %s finding lifecycle for run %s", skill.id, run_uuid) diff --git a/src/secfoo/settings.py b/src/secfoo/settings.py index 095d4c7..bbe4849 100644 --- a/src/secfoo/settings.py +++ b/src/secfoo/settings.py @@ -72,6 +72,8 @@ class SecfooConfig: agent_models: dict[str, str] = field(default_factory=dict) # [agents.].max_input_tokens -- pre-flight prompt-size cap (#32). agent_max_input_tokens: dict[str, int] = field(default_factory=dict) + # [skills] paths -- extra folders of custom skill definitions (#79). + skill_paths: list[str] = field(default_factory=list) def _parse_mcp_server(name: str, raw: dict) -> MCPServerConfig: @@ -152,6 +154,13 @@ def load_config(path: Path | None = None) -> SecfooConfig: seen_names.add(name) servers.append(_parse_mcp_server(name, entry)) + skills_raw = data.get("skills", {}) + if not isinstance(skills_raw, dict): + raise ConfigError("[skills] must be a table") + skill_paths = skills_raw.get("paths", []) + if not isinstance(skill_paths, list) or not all(isinstance(p, str) and p.strip() for p in skill_paths): + raise ConfigError("skills.paths must be an array of folder paths") + memory_raw = data.get("memory", {}) if not isinstance(memory_raw, dict): raise ConfigError("[memory] must be a table") @@ -165,4 +174,5 @@ def load_config(path: Path | None = None) -> SecfooConfig: memory_enabled=memory_enabled, agent_models=agent_models, agent_max_input_tokens=agent_max_input_tokens, + skill_paths=list(skill_paths), ) diff --git a/src/secfoo/skills/loader.py b/src/secfoo/skills/loader.py index 579d391..2e64687 100644 --- a/src/secfoo/skills/loader.py +++ b/src/secfoo/skills/loader.py @@ -1,15 +1,39 @@ """Loads skill definitions from Markdown files with a small hand-parsed frontmatter block (id/name/description/version). Deliberately avoids a YAML dependency since the schema is tiny and fixed. + +Skills come from these locations, merged (#79): + +1. built-ins: `src/secfoo/skills/definitions/*.md`; +2. the user folder: `$SECFOO_HOME/skills/*.md` (default `~/.secfoo/skills/`); +3. `[skills] paths = [...]` in `~/.secfoo/config.toml`; +4. `SECFOO_SKILLS_PATH`, folders separated by `os.pathsep` (for CI); +5. one-off files registered with `register_skill_file()` (`secfoo run --skill-file`). + +Skills are **never** loaded from the target being scanned: a cloned repo +could ship a `.secfoo/skills/` folder of prompt instructions. Only the +locations above count, and none of them is derived from the target. + +Built-ins fail hard on any problem. A malformed or clashing *user* skill is +skipped with a one-line warning instead, so one bad file can't break every +command. User folders are re-read when a file's mtime changes, so a running +`secfoo serve` picks up new or edited skills without a restart. """ from __future__ import annotations +import hashlib +import os +import re +import sys from dataclasses import dataclass from functools import lru_cache from pathlib import Path DEFINITIONS_DIR = Path(__file__).parent / "definitions" +SKILLS_PATH_ENV = "SECFOO_SKILLS_PATH" +ID_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +GENERIC_OUTPUT = "generic" class SkillLoadError(RuntimeError): @@ -28,11 +52,29 @@ class Skill: # fit a 16rem sidebar. Falls back to `name` when the frontmatter # omits it. short_name: str = "" + # #79: for a custom skill, "generic" or the id of a built-in whose + # output contract and findings lifecycle it reuses. Built-ins use their + # own id (see output_type). + output: str = GENERIC_OUTPUT + category: str = "" + source: str = "builtin" # builtin / user / file + path: Path | None = None + sha256: str = "" # of the definition file, recorded on every run @property def nav_label(self) -> str: return self.short_name or self.name + @property + def is_builtin(self) -> bool: + return self.source == "builtin" + + @property + def output_type(self) -> str: + """Which built-in's contract and findings lifecycle a run uses: + the skill's own id for a built-in, else its `output` field.""" + return self.id if self.is_builtin else self.output + def _parse_frontmatter(text: str, *, source: Path) -> tuple[dict[str, str], str]: if not text.startswith("---"): @@ -43,7 +85,7 @@ def _parse_frontmatter(text: str, *, source: Path) -> tuple[dict[str, str], str] _, raw_frontmatter, body = parts fields: dict[str, str] = {} for line in raw_frontmatter.strip().splitlines(): - if not line.strip(): + if not line.strip() or line.lstrip().startswith("#"): continue if ":" not in line: raise SkillLoadError(f"{source}: bad frontmatter line {line!r}") @@ -52,23 +94,51 @@ def _parse_frontmatter(text: str, *, source: Path) -> tuple[dict[str, str], str] return fields, body.strip() -def _load_file(path: Path) -> Skill: - fields, body = _parse_frontmatter(path.read_text(encoding="utf-8"), source=path) +def _load_file(path: Path, *, source: str = "builtin") -> Skill: + raw = path.read_bytes() + fields, body = _parse_frontmatter(raw.decode("utf-8"), source=path) try: - return Skill( + skill = Skill( id=fields["id"], name=fields["name"], description=fields["description"], version=int(fields["version"]), body=body, short_name=fields.get("short_name", ""), + output=fields.get("output", GENERIC_OUTPUT) or GENERIC_OUTPUT, + category=fields.get("category", ""), + source=source, + path=path, + sha256=hashlib.sha256(raw).hexdigest(), ) except KeyError as exc: raise SkillLoadError(f"{path}: missing required frontmatter field {exc}") from exc + except ValueError: + raise SkillLoadError(f"{path}: version must be an integer, got {fields.get('version')!r}") from None + if source != "builtin": + _validate_custom(skill) + return skill + + +def _validate_custom(skill: Skill) -> None: + where = skill.path + if not ID_RE.match(skill.id): + raise SkillLoadError(f"{where}: id {skill.id!r} must be lowercase kebab-case (e.g. graphql-review)") + for field_name in ("name", "description"): + if not getattr(skill, field_name): + raise SkillLoadError(f"{where}: {field_name} must not be empty") + if not skill.body: + raise SkillLoadError(f"{where}: the skill has no instructions after the frontmatter") + builtins = builtin_skills() + if skill.output != GENERIC_OUTPUT and skill.output not in builtins: + raise SkillLoadError( + f"{where}: output must be 'generic' or a built-in skill id ({', '.join(sorted(builtins))}), " + f"got {skill.output!r}" + ) @lru_cache(maxsize=1) -def load_all_skills() -> dict[str, Skill]: +def builtin_skills() -> dict[str, Skill]: skills: dict[str, Skill] = {} for path in sorted(DEFINITIONS_DIR.glob("*.md")): skill = _load_file(path) @@ -78,6 +148,146 @@ def load_all_skills() -> dict[str, Skill]: return skills +# ---------- user locations ---------- + + +def user_skill_dirs() -> list[tuple[Path, str]]: + """(folder, label) for every user location, in precedence order. Only + fixed places from the user's own setup -- never the scan target.""" + from secfoo.config import STORE_DIR + from secfoo.settings import ConfigError, load_config + + dirs: list[tuple[Path, str]] = [(STORE_DIR / "skills", "user")] + try: + configured = load_config().skill_paths + except ConfigError as exc: + _warn(f"ignoring [skills] paths: {exc}") + configured = [] + dirs += [(Path(p).expanduser(), "path") for p in configured] + env = os.environ.get(SKILLS_PATH_ENV, "") + dirs += [(Path(p).expanduser(), "path") for p in env.split(os.pathsep) if p.strip()] + seen: set[Path] = set() + unique = [] + for folder, label in dirs: + key = folder.resolve() if folder.exists() else folder + if key not in seen: + seen.add(key) + unique.append((folder, label)) + return unique + + +def _user_files() -> list[tuple[Path, str]]: + files = [] + for folder, label in user_skill_dirs(): + if folder.is_dir(): + files.extend((path, label) for path in sorted(folder.glob("*.md")) if path.is_file()) + return files + + +_warned: set[str] = set() + + +def _warn(message: str) -> None: + if message not in _warned: + _warned.add(message) + print(f"secfoo: warning: {message}", file=sys.stderr) + + +_registered: dict[str, Skill] = {} +_cache: dict[str, object] = {"signature": None, "skills": None, "problems": []} + + +def _signature() -> tuple: + entries = [] + for path, label in _user_files(): + try: + entries.append((str(path), label, path.stat().st_mtime_ns)) + except OSError: + continue + return tuple(entries), tuple(sorted(_registered)) + + +def _load_user_skills(builtins: dict[str, Skill]) -> tuple[dict[str, Skill], list[str]]: + skills: dict[str, Skill] = {} + problems: list[str] = [] + for path, label in _user_files(): + try: + skill = _load_file(path, source="user" if label == "user" else "path") + except (SkillLoadError, OSError, UnicodeDecodeError) as exc: + problems.append(f"skipped custom skill: {exc}") + continue + clash = builtins.get(skill.id) or skills.get(skill.id) + if clash is not None: + problems.append( + f"skipped custom skill {path}: id {skill.id!r} is already used by {clash.path} " + "(custom skills can't override another skill; rename one)" + ) + continue + skills[skill.id] = skill + return skills, problems + + +def load_all_skills() -> dict[str, Skill]: + """Built-ins plus every valid user skill. Cheap to call often: user + folders are only re-read when a file is added, removed or edited.""" + builtins = builtin_skills() + signature = _signature() + if _cache["signature"] != signature: + user, problems = _load_user_skills(builtins) + merged = {**builtins, **user} + for skill in _registered.values(): + merged.setdefault(skill.id, skill) + _cache.update(signature=signature, skills=merged, problems=problems) + for problem in problems: + _warn(problem) + return _cache["skills"] # type: ignore[return-value] + + +def load_problems() -> list[str]: + """Why custom skills were skipped on the last load (for `secfoo skills`).""" + load_all_skills() + return list(_cache["problems"]) # type: ignore[arg-type] + + +def register_skill_file(path: Path) -> Skill: + """Load one file for this process only (`secfoo run --skill-file`). + Raises SkillLoadError -- an explicitly named file should fail loudly.""" + skill = _load_file(path, source="file") + existing = load_all_skills().get(skill.id) + if existing is not None and existing.path != skill.path: + raise SkillLoadError(f"{path}: id {skill.id!r} is already used by {existing.path}") + _registered[skill.id] = skill + return skill + + +def validate_files(paths: list[Path]) -> tuple[list[Skill], list[str]]: + """Parse and check `paths` (files or folders) as custom skills, as + `secfoo skills validate` does. Returns (valid skills, problems).""" + files: list[Path] = [] + problems: list[str] = [] + for path in paths: + if path.is_dir(): + files.extend(sorted(path.glob("*.md"))) + elif path.is_file(): + files.append(path) + else: + problems.append(f"{path}: no such file or folder") + builtins = builtin_skills() + valid: dict[str, Skill] = {} + for path in files: + try: + skill = _load_file(path, source="path") + except (SkillLoadError, OSError, UnicodeDecodeError) as exc: + problems.append(str(exc)) + continue + clash = builtins.get(skill.id) or valid.get(skill.id) + if clash is not None: + problems.append(f"{path}: id {skill.id!r} is already used by {clash.path}") + continue + valid[skill.id] = skill + return list(valid.values()), problems + + def load_skill(skill_id: str) -> Skill: skills = load_all_skills() try: @@ -85,3 +295,15 @@ def load_skill(skill_id: str) -> Skill: except KeyError: available = ", ".join(sorted(skills)) or "" raise SkillLoadError(f"unknown skill id {skill_id!r}. Available: {available}") from None + + +def _reset_for_tests() -> None: + _registered.clear() + _warned.clear() + _cache.update(signature=None, skills=None, problems=[]) + + +__all__ = [ + "Skill", "SkillLoadError", "builtin_skills", "load_all_skills", "load_problems", "load_skill", + "register_skill_file", "user_skill_dirs", "validate_files", +] diff --git a/src/secfoo/skills/renderer.py b/src/secfoo/skills/renderer.py index bec3a77..baea98e 100644 --- a/src/secfoo/skills/renderer.py +++ b/src/secfoo/skills/renderer.py @@ -894,6 +894,10 @@ def render_prompt( sections.append(confluence) if memory_enabled: sections.append(_memory_section(stack, depth)) - contract = SKILL_OUTPUT_CONTRACTS.get(skill.id) + # A custom skill with `output: sast` gets the SAST contract (#79), so its + # report parses into the same findings lifecycle. The safety preamble, + # target, depth and contract always wrap a custom skill's body: it can + # add instructions but can't remove these. + contract = SKILL_OUTPUT_CONTRACTS.get(skill.output_type) sections.append(contract or OUTPUT_CONTRACT.format(skill_name=skill.name)) return "\n\n".join(sections) diff --git a/src/secfoo/storage/models.py b/src/secfoo/storage/models.py index 499a87f..03efc3e 100644 --- a/src/secfoo/storage/models.py +++ b/src/secfoo/storage/models.py @@ -46,6 +46,10 @@ class RunRecord: memory_enabled: int | None = None # 1/0: secfoo-memory tools wired in; NULL = unknown (older run). # Memory Bank: paths an incremental scan covered; None = full scan. scan_scope: list[str] | None = None + # #79: the skill definition that produced this run (NULL on older runs). + skill_version: int | None = None + skill_source: str | None = None + skill_sha256: str | None = None @dataclass(frozen=True) diff --git a/src/secfoo/storage/repository.py b/src/secfoo/storage/repository.py index d428f80..e9f6f62 100644 --- a/src/secfoo/storage/repository.py +++ b/src/secfoo/storage/repository.py @@ -80,6 +80,10 @@ def use_db_path(path: Path): "memory_enabled": "ALTER TABLE runs ADD COLUMN memory_enabled INTEGER", # Memory Bank: JSON list of paths an incremental scan covered; NULL = full scan. "scan_scope": "ALTER TABLE runs ADD COLUMN scan_scope TEXT", + # #79: skill version / builtin-or-user / hash of the definition file. + "skill_version": "ALTER TABLE runs ADD COLUMN skill_version INTEGER", + "skill_source": "ALTER TABLE runs ADD COLUMN skill_source TEXT", + "skill_sha256": "ALTER TABLE runs ADD COLUMN skill_sha256 TEXT", } _ASSESSMENTS_MIGRATIONS = { @@ -259,12 +263,17 @@ def create_run( confluence_urls: list[str], assessment_id: int | None = None, model: str | None = None, + skill_version: int | None = None, + skill_source: str | None = None, + skill_sha256: str | None = None, ) -> str: run_uuid = str(uuid.uuid4()) self._conn.execute( "INSERT INTO runs (run_uuid, project_id, assessment_id, skill_id, skill_name, agent_id, " - "confluence_urls, status, started_at, model) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?)", - (run_uuid, project_id, assessment_id, skill_id, skill_name, agent_id, json.dumps(confluence_urls), _now(), model), + "confluence_urls, status, started_at, model, skill_version, skill_source, skill_sha256) " + "VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?)", + (run_uuid, project_id, assessment_id, skill_id, skill_name, agent_id, json.dumps(confluence_urls), _now(), + model, skill_version, skill_source, skill_sha256), ) self._conn.commit() return run_uuid diff --git a/src/secfoo/storage/schema.sql b/src/secfoo/storage/schema.sql index 9156e81..4c5638a 100644 --- a/src/secfoo/storage/schema.sql +++ b/src/secfoo/storage/schema.sql @@ -81,7 +81,13 @@ CREATE TABLE IF NOT EXISTS runs ( -- Memory Bank: JSON list of the paths an incremental scan covered (files -- sent plus files deleted). NULL means a full scan. Findings lifecycle -- only closes findings inside this set for an incremental run. - scan_scope TEXT + scan_scope TEXT, + -- #79: which definition text produced this report. Custom skills change + -- over time, so version alone isn't enough: sha256 is a hash of the + -- skill's Markdown file. NULL for runs from before these existed. + skill_version INTEGER, + skill_source TEXT, -- builtin / user + skill_sha256 TEXT ); -- Files manually uploaded to an assessment (AI-BOM inventories, or other diff --git a/src/secfoo/web/static/css/dashboard.css b/src/secfoo/web/static/css/dashboard.css index b4e9832..a694b41 100644 --- a/src/secfoo/web/static/css/dashboard.css +++ b/src/secfoo/web/static/css/dashboard.css @@ -418,3 +418,5 @@ tr.run-row:hover { background-color: var(--color-primary-50); } tokens only, no new colors. */ .table tr.runtime-conflict > td { background-color: var(--color-error-bg); } .table tr.runtime-conflict > td:first-child { box-shadow: inset 3px 0 0 var(--color-error); } +/* #79: category label inside the sidebar's "Custom skills" section. */ +.sidebar__group-label { display: block; padding: var(--space-2) var(--space-3) 0; opacity: 0.7; } diff --git a/src/secfoo/web/templates/assessments/run.html b/src/secfoo/web/templates/assessments/run.html index 7441025..7ff04b8 100644 --- a/src/secfoo/web/templates/assessments/run.html +++ b/src/secfoo/web/templates/assessments/run.html @@ -30,7 +30,7 @@

Run Assessment

{% for s in skills %} {% endfor %} diff --git a/src/secfoo/web/templates/base.html b/src/secfoo/web/templates/base.html index 7d5ce2c..255a2d2 100644 --- a/src/secfoo/web/templates/base.html +++ b/src/secfoo/web/templates/base.html @@ -36,6 +36,22 @@ {% endfor %} + {% if sidebar_custom_skills %} + + + {% for group in sidebar_custom_skills %} + {% if group.label != "Custom" or loop.length > 1 %}{{ group.label }}{% endif %} + + {% endfor %} + {% endif %} +