Findings Trends
Paths. analysis-results/… and progress-tracker/… in this skill are the
default workspace layout. They resolve through locations.yaml in
$TRAUST_CONFIG_HOME (docs/setup.md, Storage locations); substitute your
configured roots.
Track whether the portfolio is getting better or worse. Because the
track-findings disposition layers are append-only ledgers of timestamped
events, the portfolio's state at any past date can be reconstructed by
replaying only the events dated before it — no snapshots, no stored trend
state, fully deterministic. This skill runs that replay at monthly (or
weekly) boundaries and reports the direction of every metric.
When to Use
- "Are findings trending up or down?" / "are we getting better?"
- "Show me the burndown" / "remediation velocity" / "MTTR"
- "How much risk have we accepted?" / "what's on the accepted-risk register?"
- "Rebuild the trends dashboard"
Invocation
python3 harnessing/findings-trends/scripts/build_trends.py $ARGUMENTS
[--results-root DIR] # default ../analysis-results
[--granularity month|week] # default month
[--since YYYY-MM]
[--product <name>] # one product directory slice
[--as-of YYYY-MM-DD] # pin the series end (reproducible runs)
[--out-dir DIR] # default <results-root>/trends
Outputs findings-trends.{json,md,html} into progress-tracker/metrics/dashboards/trends/. The md/html outputs embed the standard population block (roots, unit, filters, denominator) for cross-dashboard reconciliation, plus an "Ownership cuts (latest bucket)" section splitting open findings, open C/H, and risk index between owned (findings/, Hybrid Platforms) and upstream (oss-findings/) — upstream is never folded into the owned headline.
Semantics (fixed by design decisions, 2026-07-10)
- Option A open counting. Every finding from every audited repo counts
as open until a ledger event closes it. Repos without ledgers therefore
contribute permanent open exposure — that is the point: total exposure is
what leadership needs, and the ledger-coverage figure is printed next to
every headline so partial coverage can't masquerade as a trend.
- Accepted risk is not remediation.
risk_accepted findings leave
"open" but land on a standing Accepted Risk Register — unfixed and
unmitigated, reported with severity, CVSS, who accepted, when, and the
recorded rationale. The register never decays; it shrinks only through
remediation or re-triage. The accepted-risk CVSS sum is its own trend
series, distinct from the open risk index.
- Risk index = CVSS sum. Sum of
cvss.score over open findings;
findings without CVSS use their severity band midpoint (critical 9.5,
high 8.0, medium 5.5, low 2.0, informational 0). Custom weighting is
a deliberate non-goal for now.
- Buckets use
occurred_at. Events bucket by their source timestamp
(commit date, Jira transition, MR comment date) when the adapters
recorded one, falling back to recorded_at — late ingestion does not
distort the series.
- Countersign rule honoured. State replay reuses the track-findings
merge engine (
derive_disposition), so a machine-refuted finding stays
open until an LDAP-verified human countersigns the false positive.
Metrics and polarity
| Metric |
Good direction |
| Open findings, risk index, new findings, MTTR, FP rate, regressions, accepted risk index |
↓ |
| Remediation velocity, ledger coverage |
↑ |
Direction = last bucket vs the mean of up to three prior buckets, with a
±10% dead band rendered as → flat. Worsening metrics carry a ⚠ flag.
Procedure
Refresh the exploitation feeds (pull-through cache; no scheduler
needed — skip silently offline, the builder falls back to the stale
copy or to CVSS-only likelihood):
python3 -m traust.cli feeds fetch # EPSS + CISA KEV, 24h freshness
Resolve arguments; run the builder (above). It walks
findings/**/*-security-audit.json (plus oss-findings/), pairs each
audit with its sibling *-findings-layer.json, and replays. When the
feeds cache is present, CVE-bearing findings gain an
exploitation-evidence likelihood factor (KEV membership / EPSS
band — threat_intel_factor in risk-rating-methodology.json); the
dashboard's methodology note records feed versions and staleness.
Read findings-trends.md back to the user. Lead with the direction
table, then call out:
- any worsening ⚠ metric, with the driving repos where identifiable
- the Accepted Risk Register headcount and its highest-CVSS entries
- the ledger coverage figure and its trend — if coverage is low,
say explicitly that the open count is dominated by untracked repos
Offer the HTML dashboard path for sharing.
Gotchas
- The final bucket is usually partial. A mid-month run undercounts the
current month; the classifier still uses it. For board-ready numbers run
with
--as-of set to the last day of the previous month.
- Trends only move when ledgers move. If nothing was ingested since
the last run, the series is identical — flat is the correct answer, not
a bug. Coverage ↑ is the first trend to chase on a new portfolio.
- Don't hand-compute the numbers. All aggregation lives in
build_trends.py; the skill presents, it never re-derives.
- A shrinking open count with shrinking coverage is a red flag — check
whether repos were removed from the findings tree rather than remediated.
1---2name: findings-trends3description: Use when the user asks whether portfolio security findings are trending up or down, wants a burndown, remediation velocity, MTTR, risk-index, or accepted-risk trend, or asks to (re)build the findings-trends dashboard — e.g. "are we getting better", "findings over time", "how fast are we remediating", "what risk have we accepted". Replays every repo's track-findings disposition ledger against time buckets and emits findings-trends.{json,md,html} with direction arrows per metric.4---56# Findings Trends78> **Paths.** `analysis-results/…` and `progress-tracker/…` in this skill are the9> default workspace layout. They resolve through `locations.yaml` in10> `$TRAUST_CONFIG_HOME` (`docs/setup.md`, Storage locations); substitute your11> configured roots.121314Track whether the portfolio is getting better or worse. Because the15`track-findings` disposition layers are append-only ledgers of timestamped16events, the portfolio's state at **any past date** can be reconstructed by17replaying only the events dated before it — no snapshots, no stored trend18state, fully deterministic. This skill runs that replay at monthly (or19weekly) boundaries and reports the direction of every metric.2021## When to Use2223- "Are findings trending up or down?" / "are we getting better?"24- "Show me the burndown" / "remediation velocity" / "MTTR"25- "How much risk have we accepted?" / "what's on the accepted-risk register?"26- "Rebuild the trends dashboard"2728## Invocation2930```31python3 harnessing/findings-trends/scripts/build_trends.py $ARGUMENTS32 [--results-root DIR] # default ../analysis-results33 [--granularity month|week] # default month34 [--since YYYY-MM]35 [--product <name>] # one product directory slice36 [--as-of YYYY-MM-DD] # pin the series end (reproducible runs)37 [--out-dir DIR] # default <results-root>/trends38```3940Outputs `findings-trends.{json,md,html}` into `progress-tracker/metrics/dashboards/trends/`. The md/html outputs embed the standard population block (roots, unit, filters, denominator) for cross-dashboard reconciliation, plus an "Ownership cuts (latest bucket)" section splitting open findings, open C/H, and risk index between owned (`findings/`, Hybrid Platforms) and upstream (`oss-findings/`) — upstream is never folded into the owned headline.4142## Semantics (fixed by design decisions, 2026-07-10)4344- **Option A open counting.** Every finding from every audited repo counts45 as *open* until a ledger event closes it. Repos without ledgers therefore46 contribute permanent open exposure — that is the point: total exposure is47 what leadership needs, and the ledger-coverage figure is printed next to48 every headline so partial coverage can't masquerade as a trend.49- **Accepted risk is not remediation.** `risk_accepted` findings leave50 "open" but land on a standing **Accepted Risk Register** — unfixed and51 unmitigated, reported with severity, CVSS, who accepted, when, and the52 recorded rationale. The register never decays; it shrinks only through53 remediation or re-triage. The accepted-risk CVSS sum is its own trend54 series, distinct from the open risk index.55- **Risk index = CVSS sum.** Sum of `cvss.score` over open findings;56 findings without CVSS use their severity band midpoint (critical 9.5,57 high 8.0, medium 5.5, low 2.0, informational 0). Custom weighting is58 a deliberate non-goal for now.59- **Buckets use `occurred_at`.** Events bucket by their source timestamp60 (commit date, Jira transition, MR comment date) when the adapters61 recorded one, falling back to `recorded_at` — late ingestion does not62 distort the series.63- **Countersign rule honoured.** State replay reuses the track-findings64 merge engine (`derive_disposition`), so a machine-refuted finding stays65 open until an LDAP-verified human countersigns the false positive.6667## Metrics and polarity6869| Metric | Good direction |70|---|---|71| Open findings, risk index, new findings, MTTR, FP rate, regressions, accepted risk index | ↓ |72| Remediation velocity, ledger coverage | ↑ |7374Direction = last bucket vs the mean of up to three prior buckets, with a75±10% dead band rendered as → flat. Worsening metrics carry a ⚠ flag.7677## Procedure78791. **Refresh the exploitation feeds** (pull-through cache; no scheduler80 needed — skip silently offline, the builder falls back to the stale81 copy or to CVSS-only likelihood):8283 ```bash84 python3 -m traust.cli feeds fetch # EPSS + CISA KEV, 24h freshness85 ```86872. Resolve arguments; run the builder (above). It walks88 `findings/**/*-security-audit.json` (plus `oss-findings/`), pairs each89 audit with its sibling `*-findings-layer.json`, and replays. When the90 feeds cache is present, CVE-bearing findings gain an91 **exploitation-evidence likelihood factor** (KEV membership / EPSS92 band — `threat_intel_factor` in `risk-rating-methodology.json`); the93 dashboard's methodology note records feed versions and staleness.943. Read `findings-trends.md` back to the user. Lead with the direction95 table, then call out:96 - any **worsening ⚠** metric, with the driving repos where identifiable97 - the **Accepted Risk Register** headcount and its highest-CVSS entries98 - the **ledger coverage** figure and its trend — if coverage is low,99 say explicitly that the open count is dominated by untracked repos1004. Offer the HTML dashboard path for sharing.101102## Gotchas103104- **The final bucket is usually partial.** A mid-month run undercounts the105 current month; the classifier still uses it. For board-ready numbers run106 with `--as-of` set to the last day of the previous month.107- **Trends only move when ledgers move.** If nothing was ingested since108 the last run, the series is identical — flat is the correct answer, not109 a bug. Coverage ↑ is the first trend to chase on a new portfolio.110- **Don't hand-compute the numbers.** All aggregation lives in111 `build_trends.py`; the skill presents, it never re-derives.112- **A shrinking open count with shrinking coverage is a red flag** — check113 whether repos were removed from the findings tree rather than remediated.