Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ Screenshots are from a real local `secfoo serve` against public vulnerable-app t
- **Local first.** Runs and `secfoo serve` stay on your machine. Optional
`secfoo cloud login` syncs to an org portal that is not in this repo.
- **Concurrent by default.** Several `--skill` flags run at the same time.
- **Portable findings.** Export SAST, SCA, and secret-scanning findings as
JSON Lines or SARIF 2.1.0 for GitHub code scanning and IDEs. See
[export examples](docs/REFERENCE.md#structured-findings-export).
- **CI-ready.** `--fail-on high`, `--max-cost 2.00`, `--json`, and distinct
exit codes for "the scan broke" vs "policy failed". Details in
[docs/REFERENCE.md](docs/REFERENCE.md#ci-gates-and-machine-readable-output).
Expand Down
47 changes: 47 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,12 +297,59 @@ records:
Secret evidence is cut back down to its first 8 characters before it is
stored, even when a report includes more.

### Structured findings export

```bash
secfoo findings export # open findings, every skill, JSON Lines to stdout
secfoo findings export -s sast --status all # one skill, open and closed
secfoo findings export --no-locations -o findings.jsonl # drop paths/lines/region hashes
```


Use `--format sarif` to write a SARIF 2.1.0 document instead of JSON Lines
(the default). The same `--skill`, `--project`, `--status`, and
`--no-locations` options apply to both formats. Export one project at a time
when uploading to code scanning so paths belong to the checked-out repository.
Relative paths should be relative to that repository; absolute paths are emitted
as `file:` URIs. Missing files or invalid line numbers omit the corresponding
location or region rather than inventing one.

```bash
secfoo findings export --format sarif --project 1 -o findings.sarif
```

After a scan has populated secfoo's local findings database, upload the export
in a GitHub Actions job (the checkout, scan and export must use the same runner
and secfoo store):

```yaml
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
# Install secfoo and run your assessment here to populate the local store.
- name: Export findings
run: secfoo findings export --format sarif --project 1 -o findings.sarif
- uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: findings.sarif
category: secfoo
```

Replace `1` with the scanned project's local ID. GitHub code scanning must be
available and enabled for the repository. Use the default open status for uploads;
`--status all` also exports closed findings as results.

SARIF rules are grouped by skill, CWE and severity so different severities for
the same CWE retain their own rule-level `security-severity`. Critical/high map
to `error`, medium to `warning`, and low/informational to `note`. Their categorical
security scores are `9.0`, `7.0`, `4.0`, `1.0`, and `0.0`, respectively; these are
not calculated CVSS scores. Unknown severities use `warning` without a security
score. CWE tags use `external/cwe/cwe-N`. Findings without a CWE still get a rule.
`--no-locations` removes the same structured location fields as JSONL and omits
SARIF locations; free-text finding titles and descriptions are not redacted.

## Install

Pick whichever fits how you work — they're all the same tool underneath.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ dependencies = [
]

[project.optional-dependencies]
dev = ["pytest>=8.0", "httpx>=0.27", "ruff>=0.4", "bandit>=1.8", "pip-audit>=2.7", "langgraph>=0.2"]
dev = ["jsonschema>=4.0", "pytest>=8.0", "httpx>=0.27", "ruff>=0.4", "bandit>=1.8", "pip-audit>=2.7", "langgraph>=0.2"]
build = ["pyinstaller>=6.0"]
# The `api` agent (secfoo/agents/api.py): a LangGraph workflow that calls OpenAI/Anthropic/Gemini
# through LiteLLM with an API key instead of driving an installed agent CLI.
Expand Down
25 changes: 18 additions & 7 deletions src/secfoo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from datetime import date, datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Optional
from typing import Annotated, Optional

import typer
from rich.console import Console
Expand All @@ -23,6 +23,7 @@
from secfoo.cloud import CloudConfig, CloudError
from secfoo.config import CLOUD_CONFIG_PATH, attachment_dir
from secfoo.cost import format_cost, format_tokens, sum_optional
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
Expand Down Expand Up @@ -1444,6 +1445,11 @@ class FindingsStatus(str, Enum):
ALL = "all"


class FindingsFormat(str, Enum):
JSONL = "jsonl"
SARIF = "sarif"


# Lifecycle bookkeeping that means nothing outside this one SQLite file.
_EXPORT_DROP_COLUMNS = {"id", "project_id", "first_seen_run_id", "last_seen_run_id", "closed_in_run_id"}
_EXPORT_LOCATION_COLUMNS = {"location_file", "location_line", "code_region_hash", "reachability_evidence"}
Expand All @@ -1454,13 +1460,14 @@ def findings_export(
skill: list[FindingsSkill] = typer.Option([], "--skill", "-s", help="Limit to these skills. Repeatable."),
project: Optional[int] = typer.Option(None, "--project", help="Limit to one project id."),
status: FindingsStatus = typer.Option(FindingsStatus.OPEN, "--status", help="open, closed, or all."),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write JSONL here instead of stdout."),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write the export here instead of stdout."),
format: Annotated[FindingsFormat, typer.Option("--format", help="Export format: jsonl or sarif.")] = FindingsFormat.JSONL,
no_locations: bool = typer.Option(
False, "--no-locations",
help="Drop file paths, line numbers, code-region hashes and reachability evidence.",
),
) -> None:
"""Export structured findings (SAST, SCA, secret scanning) as JSON Lines.
"""Export structured findings (SAST, SCA, secret scanning) as JSONL or SARIF.

One record per finding, with its skill, severity, stack (language/
framework) and code-free construct description -- the same shape for
Expand All @@ -1472,20 +1479,24 @@ def findings_export(
project_id=project,
status=None if status is FindingsStatus.ALL else status.value,
)
lines = []
records = []
for skill_id, row in rows:
record = {"skill_id": skill_id, "project": row.pop("display_name", None)}
for column, value in row.items():
if column in _EXPORT_DROP_COLUMNS or (no_locations and column in _EXPORT_LOCATION_COLUMNS):
continue
record[column] = value
lines.append(json.dumps(record, sort_keys=True))
text = "\n".join(lines) + ("\n" if lines else "")
records.append(record)
if format is FindingsFormat.SARIF:
text = json.dumps(findings_to_sarif(records), indent=2, sort_keys=True) + "\n"
else:
lines = [json.dumps(record, sort_keys=True) for record in records]
text = "\n".join(lines) + ("\n" if lines else "")
if output is None:
sys.stdout.write(text)
return
output.write_text(text, encoding="utf-8")
err_console.print(f"Wrote {len(lines)} finding(s) to {output}.")
err_console.print(f"Wrote {len(records)} finding(s) to {output}.")


MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"
Expand Down
96 changes: 96 additions & 0 deletions src/secfoo/report/sarif.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Serialize exported structured findings as SARIF 2.1.0, without I/O."""

from __future__ import annotations

import re
from pathlib import PureWindowsPath
from urllib.parse import quote

from secfoo.__about__ import __version__

SCHEMA_URI = "https://json.schemastore.org/sarif-2.1.0.json"
# Scores represent secfoo's categorical severity, not calculated CVSS scores.
_SEVERITIES = {
"critical": ("error", "9.0"),
"high": ("error", "7.0"),
"medium": ("warning", "4.0"),
"low": ("note", "1.0"),
"info": ("note", "0.0"),
"informational": ("note", "0.0"),
}


def _location(record: dict) -> dict | None:
path = str(record.get("location_file") or "").strip().replace("\\", "/")
if not path:
return None
if PureWindowsPath(path).is_absolute():
uri = PureWindowsPath(path).as_uri()
elif path.startswith("/"):
uri = "file://" + quote(path, safe="/")
else:
uri = quote(path, safe="/")
physical = {"artifactLocation": {"uri": uri}}
# Stored legacy/model-authored lines are not guaranteed to be numeric.
line = str(record.get("location_line") or "").strip()
if re.fullmatch(r"[0-9]+", line) and int(line) > 0:
physical["region"] = {"startLine": int(line)}
return {"physicalLocation": physical}


def findings_to_sarif(records: list[dict]) -> dict:
"""Build one run; group rules by skill, CWE and severity.

Severity is part of the rule identity because GitHub reads security-severity
from the rule, while findings with the same CWE can have different levels.
Only explicit result fields are copied: internal IDs and secret evidence
are not needed by code scanning.
"""
rules = []
rule_indices = {}
results = []
for record in records:
skill = record["skill_id"]
severity = str(record.get("severity") or "").strip().lower()
level, score = _SEVERITIES.get(severity, ("warning", None))
severity_key = "info" if severity == "informational" else severity
severity_key = severity_key if severity_key in _SEVERITIES else "unknown"
cwes = sorted(set(re.findall(r"\bCWE-[1-9][0-9]*\b", str(record.get("cwe") or "").upper())))
rule_id = "/".join([skill, *cwes, severity_key])
if rule_id not in rule_indices:
properties = {"tags": ["security", *[f"external/cwe/{cwe.lower()}" for cwe in cwes]]}
if score is not None:
properties["security-severity"] = score
rule_indices[rule_id] = len(rules)
rules.append({
"id": rule_id,
"shortDescription": {"text": f"{skill} finding ({', '.join(cwes) or 'unclassified'}, {severity_key})"},
"defaultConfiguration": {"level": level},
"properties": properties,
})
title = record.get("title") or record.get("issue") or record.get("secret_type") or f"{skill} finding"
description = record.get("description") or record.get("construct")
message = str(title) + (f"\n\n{description}" if description else "")
result = {
"ruleId": rule_id,
"ruleIndex": rule_indices[rule_id],
"level": level,
"message": {"text": message},
}
location = _location(record)
if location is not None:
result["locations"] = [location]
results.append(result)
return {
"$schema": SCHEMA_URI,
"version": "2.1.0",
"runs": [{
"tool": {"driver": {
"name": "secfoo",
"version": __version__,
"informationUri": "https://github.com/secfoo-com/secfoo",
"rules": rules,
}},
"results": results,
}],
}
11 changes: 11 additions & 0 deletions tests/fixtures/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# SARIF schema fixture

`sarif-schema-2.1.0.json` is the complete SARIF 2.1.0 JSON Schema,
retrieved from https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json
on 2026-11-08. Only line endings were normalized from CRLF to LF. It is the normative schema published by OASIS, not a reduced test schema.
Keeping it here allows format validation without network access during tests.

Copyright © OASIS Open 2020. All Rights Reserved. The OASIS copyright,
copying permissions and disclaimers are reproduced in `SARIF-NOTICES.txt`.

SARIF specification: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
65 changes: 65 additions & 0 deletions tests/fixtures/SARIF-NOTICES.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
Copyright © OASIS Open 2020. All Rights Reserved.

All capitalized terms in the following text have the
meanings assigned to them in the OASIS Intellectual Property Rights Policy (the
"OASIS IPR Policy"). The full Policy may be
found at the OASIS website.

This document and translations of it may be copied and
furnished to others, and derivative works that comment on or otherwise explain
it or assist in its implementation may be prepared, copied, published, and
distributed, in whole or in part, without restriction of any kind, provided
that the above copyright notice and this section are included on all such
copies and derivative works. However, this document itself may not be modified
in any way, including by removing the copyright notice or references to OASIS,
except as needed for the purpose of developing any document or deliverable
produced by an OASIS Technical Committee (in which case the rules applicable to
copyrights, as set forth in the OASIS IPR Policy, must be followed) or as
required to translate it into languages other than English.

The limited permissions granted above are perpetual and will
not be revoked by OASIS or its successors or assigns.

This document and the information contained herein is
provided on an "AS IS" basis and OASIS DISCLAIMS ALL WARRANTIES,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF
THE INFORMATION HEREIN WILL NOT INFRINGE ANY OWNERSHIP RIGHTS OR ANY IMPLIED
WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.

OASIS requests that any OASIS Party or any other party that
believes it has patent claims that would necessarily be infringed by
implementations of this OASIS Committee Specification or OASIS Standard, to
notify OASIS TC Administrator and provide an indication of its willingness to
grant patent licenses to such patent claims in a manner consistent with the IPR
Mode of the OASIS Technical Committee that produced this specification.

OASIS invites any party to contact the OASIS TC
Administrator if it is aware of a claim of ownership of any patent claims that
would necessarily be infringed by implementations of this specification by a
patent holder that is not willing to provide a license to such patent claims in
a manner consistent with the IPR Mode of the OASIS Technical Committee that
produced this specification. OASIS may include such claims on its website, but
disclaims any obligation to do so.

OASIS takes no position regarding the validity or scope of
any intellectual property or other rights that might be claimed to pertain to
the implementation or use of the technology described in this document or the
extent to which any license under such rights might or might not be available;
neither does it represent that it has made any effort to identify any such
rights. Information on OASIS' procedures with respect to rights in any document
or deliverable produced by an OASIS Technical Committee can be found on the
OASIS website. Copies of claims of rights made available for publication and
any assurances of licenses to be made available, or the result of an attempt
made to obtain a general license or permission for the use of such proprietary
rights by implementers or users of this OASIS Committee Specification or OASIS
Standard, can be obtained from the OASIS TC Administrator. OASIS makes no
representation that any information or list of intellectual property rights
will at any time be complete, or that any claims in such list are, in fact,
Essential Claims.

The name "OASIS" is a trademark of OASIS, the owner and developer of this
specification, and should be used only to refer to the organization and its
official outputs. OASIS welcomes reference to, and implementation and use of,
specifications, while reserving the right to enforce its marks against
misleading uses. Please see https://www.oasis-open.org/policies-guidelines/trademark
for above guidance.
Loading
Loading