Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Diff & compare

Two related surfaces, both built on the cross-scan fingerprint identity:

  • securescan diff — what's NEW between two git refs (or two pre-scanned snapshots). The CI workhorse.
  • securescan compare — what's drifted since a saved baseline. An auditing / triage surface.

The dashboard renders both at /diff and /compare.

diff (CLI)

# Ref mode — refs must exist in the local clone
securescan diff . --base-ref main --head-ref HEAD

# Snapshot mode — recommended for CI; no second checkout required
securescan diff . \
  --base-snapshot before.json \
  --head-snapshot after.json \
  --output github-pr-comment

The classifier produces three buckets keyed on fingerprint:

  • NEW — present in head, absent from base.
  • FIXED — present in base, absent from head.
  • UNCHANGED — fingerprint in both.

Only NEW is reported by default in the github-pr-comment output — that is the diff-aware-PR-comment property.

Snapshot mode is the right CI shape

Each side of the diff runs securescan scan ... --output json independently — possibly on different runners — and a single classification step does the diff without re-checking-out the tree. This decouples the heavy work from the diff logic and lets you cache each side's snapshot.

See CLI commands.

diff (dashboard)

/diff: a PR-style scan-vs-scan comparison.

PageHeader: Diff

Base:  [ scan picker ▾ ]   ↔   Head: [ scan picker ▾ ]
       0d2c... · 2026-04-29              0f1a... · 2026-04-29

Summary chips
  ▲ 3 new   ▼ 2 resolved   = 14 unchanged   Risk Δ +12.4

[ New (3) ] [ Resolved (2) ] [ Unchanged (14) ]   <- tabs

Findings table (severity-tinted, expandable rows)
  ● critical  Use of eval()                  backend/api.py:42       semgrep   ⌃
  ● high      SQL injection via str.format   backend/db.py:12        bandit    ⌃
  ● medium    Missing X-Frame-Options        (https://...)           dast      ⌃

Source: frontend/src/app/diff/page.tsx (FEAT1 from v0.6.0).

compare (CLI)

# What disappeared since the last baseline?
securescan compare .securescan/baseline.json

compare classifies findings into:

  • NEW — in current scan, not in baseline.
  • DISAPPEARED — in baseline, not in current scan.
  • STILL_PRESENT — in both.

The PR-comment marker is <!-- securescan:compare --> so a comment upserter can keep this on a separate thread from the <!-- securescan:diff --> PR-diff comment.

compare (dashboard)

/compare: same shape as /diff, framed for "current scan vs saved baseline" rather than "scan A vs scan B". Useful at end-of-sprint to confirm legacy findings were actually remediated.

API: scan-vs-scan compare

curl -H "X-API-Key: $K" \
  "http://127.0.0.1:8000/api/v1/scans/compare?scan_a=$BASE&scan_b=$HEAD" \
  | jq .

Response:

{
  "scan_a": "0d2c...",
  "scan_b": "0f1a...",
  "new": [ /* findings present in scan_b only */ ],
  "fixed": [ /* findings present in scan_a only */ ],
  "unchanged": [ /* fingerprints in both */ ]
}

Source: backend/securescan/api/scans.py::compare_scans.

CI integration

The Metbcy/securescan@v1 action runs securescan diff automatically on pull_request events, posts the upserted PR comment, and uploads SARIF — see GitHub Action. To wire diff into a custom CI:

- name: Snapshot base
  run: |
    git checkout ${{ github.base_ref }}
    securescan scan . --type code --output json --output-file before.json
- name: Snapshot head
  run: |
    git checkout ${{ github.head_ref }}
    securescan scan . --type code --output json --output-file after.json
- name: Diff
  run: |
    securescan diff . \
      --base-snapshot before.json \
      --head-snapshot after.json \
      --output github-pr-comment \
      --output-file diff.md

How fingerprints handle reformats

A reformat that does not change the matched line's meaning should not reclassify findings as NEW. The fingerprint's normalized_line_context collapses whitespace and trivial reformatting before hashing, so:

ChangeFingerprint
Reflow eval(payload)eval(\n payload\n)Stable
Replace tabs with spacesStable
Rename a variable used in the lineChanges (semantic shift)
Move the line to a different fileChanges (file_path is in the hash)

For the few cases where this is wrong (e.g. you move a function file that the scanner re-flags), the inline securescan: ignore comment travels with the code — the suppression survives the rename.

Determinism

Both diff and compare are byte-deterministic given the same inputs: the underlying securescan scan is deterministic (Findings & severity), and the classification step is a pure set difference. So the same PR push twice posts the same comment body; if the body has not changed, the upsert is a no-op.

Next