Skip to content
Open
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -807,7 +807,7 @@ The top-level shape is (this example shows a full LLM-backed scan; with `--no-ll
"skill": { "name": "...", "source": "...", "scanned_at": "<ISO 8601>" },
"risk_assessment": { "score": 0, "severity": "LOW", "recommendation": "SAFE" },
"components": [ { "path": "...", "type": "...", "lines": 0, "executable": false, "size_bytes": 0 } ],
"issues": [ { "id": "...", "category": "...", "severity": "...", "confidence": 0.0, "location": { "file": "...", "start_line": 0 } } ],
"issues": [ { "id": "...", "category": "...", "severity": "...", "confidence": 0.0, "location": { "file": "...", "start_line": 0 }, "surface": "code" } ],
"metadata": {
"has_executable_scripts": false,
"skillspector_version": "...",
Expand All @@ -834,6 +834,10 @@ The top-level shape is (this example shows a full LLM-backed scan; with `--no-ll

- `risk_assessment.severity` ∈ `LOW | MEDIUM | HIGH | CRITICAL`.
- `risk_assessment.recommendation` ∈ `SAFE | CAUTION | DO_NOT_INSTALL`, mapped from severity: `LOW → SAFE`, `MEDIUM → CAUTION`, `HIGH`/`CRITICAL → DO_NOT_INSTALL`.
- `issues[].surface` and `suppressed[].surface` are advisory, heuristic labels for where a finding landed:
`code | instructions | docs | tests | comments | config`. JSON expands compacted groups into one `issues[]` entry
per occurrence, each with its own `surface`; SARIF emits `properties.surface` on each result. The label is
path/line-based and the scanned author can influence it, so do not use it as the sole suppression or security signal.
- `metadata.llm_error` appears only when LLM analysis was requested but unavailable.
- AE1 findings use **Incomplete referenced artifact analysis**. Their source
location identifies the reference; `evidence` identifies the affected target,
Expand Down
10 changes: 10 additions & 0 deletions src/skillspector/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
from typing import TYPE_CHECKING, Protocol
from uuid import uuid4

from skillspector.surface import infer_surface

if TYPE_CHECKING:
from skillspector.state import SkillspectorState

Expand Down Expand Up @@ -161,6 +163,11 @@ class Finding:
# Canonical unbound rule+match digest. Never replace it with a source-bound digest.
match_fingerprint: str | None = None
occurrences: list[dict[str, object]] = field(default_factory=list)
# Reporting-only label for the file surface this finding landed on (see
# ``skillspector.surface``). It is advisory metadata: it never changes
# severity, confidence, scoring, or the finding count. ``None`` means
# "derive from ``file`` at serialization time".
surface: str | None = None

def fingerprint(self) -> str | None:
"""Return a full-match fingerprint without exposing the matched payload."""
Expand Down Expand Up @@ -264,6 +271,9 @@ def to_dict(self) -> dict[str, object]:
"evidence": dict(self.evidence),
"match_fingerprint": self.fingerprint(),
"occurrences": self._serialized_occurrences(),
# Exposed, never filtered on: consumers decide what a surface is
# worth, so the scanner does not hide a finding on their behalf.
"surface": self.surface or infer_surface(self.file),
}
if self.transitive_depth:
data["transitive_depth"] = self.transitive_depth
Expand Down
36 changes: 33 additions & 3 deletions src/skillspector/nodes/analyzers/static_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
build_declared_marker_views,
)
from skillspector.state import AnalyzerNodeResponse, SkillspectorState, transitive_remaining_seconds
from skillspector.surface import COMMENTS, infer_surface

from .common import (
LINE_BREAK_CHARS,
Expand Down Expand Up @@ -672,8 +673,15 @@ def _is_env_file_reference_in_docs(
def analyzer_finding_to_finding(
af: AnalyzerFinding,
get_remediation_fn: Callable[[str], str] | None = None,
*,
line_text: str | None = None,
) -> Finding:
"""Convert an AnalyzerFinding (from any analyzer) to graph-state Finding."""
"""Convert an AnalyzerFinding (from any analyzer) to graph-state Finding.

``line_text`` is the matched source line when the caller has it. It only
refines a ``code``/``config`` path into the ``comments`` surface, so callers
that cannot supply content still receive a path-derived classification.
"""
rem_fn = get_remediation_fn or get_remediation
remediation = af.remediation or rem_fn(af.rule_id)
category = (af.tags[0] if af.tags else None) or get_category(af.rule_id)
Expand Down Expand Up @@ -701,6 +709,7 @@ def analyzer_finding_to_finding(
intent=None,
evidence=dict(af.evidence),
match_fingerprint=af.match_fingerprint,
surface=infer_surface(af.location.file, line_text),
)


Expand Down Expand Up @@ -955,7 +964,28 @@ def _convert_analyzer_finding(
for triage_tag in ("contextual-triage", "likely-benign-context"):
if triage_tag not in af.tags:
af.tags.append(triage_tag)
return analyzer_finding_to_finding(af)
start_line = af.location.start_line
end_line = af.location.end_line or start_line
start_index = max(0, start_line - 1)
end_index = min(len(content_lines), end_line)
covered_lines = content_lines[start_index:end_index]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocker] content_lines comes from str.splitlines(), which also breaks lines on \v, \f, \x1c-\x1e, \x85, \u2028 and \u2029. AST-derived findings that reach this function carry CPython line numbers, which don't count those characters, for example E2 (static_patterns_data_exfiltration.py:225-233) and OH1 (static_patterns_output_handling.py:595-603). One form feed or \u2028 above a call shifts this lookup to an earlier line. Example: in import os / ^L# Collect settings for the report / env = dict(os.environ), E2 is on CPython line 3, but content_lines[2] is the comment, so the HIGH finding is labeled comments. Benign files with PEP 8 form feeds are affected too. The simplest fix is to skip the refinement when the file contains any of \v\f\x1c\x1d\x1e\x85\u2028\u2029 (computed once per file). Please add a test with this example.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 52eaf34. content_lines now normalizes CRLF/CR and splits only on CPython physical newlines, so \v, \f, \x1c-\x1e, \x85, \u2028, and \u2029 no longer shift AST line lookups. Added the form-feed E2 regression; it reports CPython line 3 and surface code. Focused pytest: 249 passed; Ruff check/format pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in c93028c. src/skillspector/nodes/analyzers/static_runner.py:981-988 now normalizes CRLF/CR and splits only on \n; _scan_path uses that helper for content_lines at static_runner.py:1007-1009. Vertical tab, form feed, \x1c-\x1e, NEL, and Unicode line/paragraph separators therefore no longer shift AST line lookups. The form-feed E2 regression is tests/test_finding_surface.py:130-139.

I also merged origin/main (2f93a86) without rebase or force-push, retaining both main's occurrence report identity/snippet propagation and this PR's surface metadata.

Focused verification:
uv run --no-sync pytest tests/test_finding_surface.py tests/nodes/test_report_occurrence_columns.py tests/nodes/analyzers/test_static_runner_filtering.py -q -> 264 passed.
uv run --no-sync ruff check ... -> All checks passed!; ruff format --check -> 7 files already formatted.

line_text = (
covered_lines[0]
if covered_lines
and all(infer_surface(path, line_text) == COMMENTS for line_text in covered_lines)
else None
)
return analyzer_finding_to_finding(af, line_text=line_text)


def _source_lines_for_line_numbers(content: str) -> list[str]:
"""Split source on physical newlines counted by CPython line numbers.

``str.splitlines`` also splits on vertical tab, form feed, file/group/record
separators, NEL, and Unicode line/paragraph separators. CPython does not
count those as lines, so using it here shifts AST-derived line lookups.
"""
return content.replace("\r\n", "\n").replace("\r", "\n").split("\n")


def _scan_path(
Expand All @@ -976,7 +1006,7 @@ def _scan_path(
file_type = _infer_file_type(path)
if python_source is None:
python_source = may_be_python_source(path, content)
content_lines = content.splitlines()
content_lines = _source_lines_for_line_numbers(content)
normalized_license_lines = (
tuple(_normalize_license_line(line) for line in content_lines)
if _is_license_basename(path, file_type)
Expand Down
24 changes: 22 additions & 2 deletions src/skillspector/nodes/deduplicate.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
OCCURRENCE_FINDING_ID_KEY,
Finding,
)
from skillspector.surface import SURFACES, infer_surface

logger = get_logger(__name__)

Expand Down Expand Up @@ -169,6 +170,23 @@ def _output_key(finding: Finding) -> tuple[object, ...]:
)


def _occurrence_surface(finding: Finding, occurrence: dict[str, object]) -> str:
"""Return the surface label to record against one occurrence.

The finding's own label is reused when the occurrence sits in the finding's
file. An occurrence pointing at another file (exact-match dedup aggregates
across files) falls back to a path-only inference, because the matched line
for that other file is not retained here.
"""
recorded_surface = occurrence.get("surface")
if isinstance(recorded_surface, str) and recorded_surface in SURFACES:
return recorded_surface
file = str(occurrence.get("file", finding.file))
if file == finding.file and finding.surface:
Comment thread
rng1995 marked this conversation as resolved.
return finding.surface
return infer_surface(file)


def deduplicate(findings: list[Finding]) -> list[Finding]:
"""Aggregate classification-equivalent exact matches while preserving occurrences."""
groups: dict[tuple[str, str, str, tuple[object, ...]], list[Finding]] = {}
Expand Down Expand Up @@ -197,7 +215,7 @@ def deduplicate(findings: list[Finding]) -> list[Finding]:
_classification_metadata,
), group in groups.items():
representative = min(group, key=_representative_key)
occurrences: dict[tuple[object, ...], tuple[object, object]] = {}
occurrences: dict[tuple[object, ...], tuple[object, object, str]] = {}
for finding in sorted(group, key=_representative_key):
for occurrence in _occurrences(finding):
location = (
Expand All @@ -219,6 +237,7 @@ def deduplicate(findings: list[Finding]) -> list[Finding]:
OCCURRENCE_CODE_SNIPPET_KEY,
finding.code_snippet or finding.context,
),
_occurrence_surface(finding, occurrence),
),
)
ordered_occurrences = [
Expand All @@ -232,6 +251,7 @@ def deduplicate(findings: list[Finding]) -> list[Finding]:
**({"source_digest": source_digest} if source_digest else {}),
**({"source_url": source_url} if source_url else {}),
**({"transitive_depth": transitive_depth} if transitive_depth else {}),
**({"surface": surface} if surface else {}),
OCCURRENCE_FINDING_ID_KEY: report_finding_id,
OCCURRENCE_CODE_SNIPPET_KEY: report_code_snippet,
}
Expand All @@ -247,7 +267,7 @@ def deduplicate(findings: list[Finding]) -> list[Finding]:
source_url,
transitive_depth,
),
(report_finding_id, report_code_snippet),
(report_finding_id, report_code_snippet, surface),
) in sorted(
occurrences.items(),
key=lambda item: (
Expand Down
20 changes: 19 additions & 1 deletion src/skillspector/nodes/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,10 +284,17 @@ def _expand_occurrences(findings: list[Finding]) -> list[Finding]:
start_column, end_column = _occurrence_columns(finding, occurrence)
provenance = _occurrence_provenance(finding, occurrence)
depth_value = provenance.get("transitive_depth")
occurrence_file = str(occurrence.get("file", finding.file))
recorded_surface = occurrence.get("surface")
occurrence_surface = (
str(recorded_surface)
if isinstance(recorded_surface, str) and recorded_surface
else (finding.surface if occurrence_file == finding.file else None)
)
expanded.append(
replace(
occurrence_finding,
file=str(occurrence.get("file", finding.file)),
file=occurrence_file,
start_line=start_line,
end_line=end_line,
start_column=start_column,
Expand All @@ -305,6 +312,10 @@ def _expand_occurrences(findings: list[Finding]) -> list[Finding]:
),
transitive_depth=depth_value if isinstance(depth_value, int) else 0,
occurrences=[],
# Prefer the occurrence's own recorded label. An occurrence
# aggregated from another file keeps the surface that file's
# finding carried; ``None`` falls back to a path-only label.
surface=occurrence_surface,
)
)
return expanded
Expand Down Expand Up @@ -375,6 +386,12 @@ def _build_sarif_properties(
"""Project selected finding metadata into a SARIF properties dictionary."""
occurrence_finding = _finding_for_occurrence(finding, occurrence or {})
finding_dict = occurrence_finding.to_dict()
recorded_surface = (occurrence or {}).get("surface")
surface = (
str(recorded_surface)
if isinstance(recorded_surface, str) and recorded_surface
else finding_dict["surface"]
)
metadata: dict[str, object] = {
"findingId": occurrence_finding.finding_id,
"severity": finding_dict["severity"],
Expand All @@ -391,6 +408,7 @@ def _build_sarif_properties(
"intent": finding_dict["intent"],
"tags": finding_dict["tags"],
"evidence": finding_dict["evidence"],
"surface": surface,
}
provenance = _occurrence_provenance(finding, occurrence)
for key, sarif_key in (
Expand Down
Loading
Loading