Skip to content

fix(scan): stop dropping exposed secrets, collapse duplicates, and type injection findings correctly - #313

Open
closerforever wants to merge 6 commits into
ArmisSecurity:mainfrom
closerforever:fix/finding-type-dedup-and-secret-gating
Open

closerforever wants to merge 6 commits into
ArmisSecurity:mainfrom
closerforever:fix/finding-type-dedup-and-secret-gating

Conversation

@closerforever

@closerforever closerforever commented Sep 2, 2026 •

Copy link
Copy Markdown

What this is

Three gating-correctness fixes in scan repo / scan image, found while
evaluating whether an Armis AppSec scan is safe to turn on as a blocking
commit gate for an internal Azure Databricks project template.

All three were measured against armis-cli v1.22.0 and the live API. They are
independent of pre-commit — they would matter to any CI use of armis-cli.

The first one is the serious one: a repository whose only problem was two
hard-coded AWS credentials scanned completely clean.

Updated after the first review round. The branch is now 6 commits, not 3.
Three commits address the eight review comments; two more fix call sites those
refactors exposed. Sections 1–3 below have been corrected where the review
changed the answer, and the two new items are §4 and §5. §6 is a deliberate
behaviour change that needs an explicit yes or no from you
— it is the one
thing here that changes output for inputs that work correctly on main.

1. Exposed secrets were dropped by the exploitability filter

Fixture containing nothing but two hard-coded AWS keys, scanned on main:

total: 0
filtered_non_exploitable: 2
exit code 0

The exploitability filter graded both secrets low/medium and removed them before
they reached the report. A gate on this is green on committed credentials.

This sits on top of #233 (PPSC-1015), which repaired the filter for the graded
label schema. #233 made the filter work; this PR carves secrets out of it, because
reachability grading does not apply to a literal secret: it is already disclosed to
everyone who can read the repository, and there is no path to reason about. Same
fixture after: 2 findings, both HIGH, exit 1.

The exemption now lives in the mechanism rather than at the call sites. Review
comment 6 asked for this and it was the right call — see §4 for the bug it caught.
ShouldFilterFinding(nf, includeNonExploitable) is what callers ask, and the
label check (ShouldFilterByExploitability) plus the secret-exposure test are
its internals.

The second half is severity. Where the backend does surface a secret, it can
arrive as severity: INFO with no CWE — so under --fail-on HIGH,CRITICAL, the
setting every CI example in the docs uses, a credential committed to source is
still a green build. --fail-on-secret, on by default, exits non-zero when a
secret is exposed whatever severity was assigned, and explains on stderr why the
scan failed when no finding met the configured threshold. --fail-on-secret=false
restores the old behaviour, and a secret suppressed via .armisignore is ignored
exactly as before, so the escape hatch is unchanged.

ShouldFail and CheckExit keep their severity-only meaning for existing callers;
the new policy lives in ExitPolicy, which scan repo, scan image and — as of
§5 — scan sbom pass.

2. An injection finding was typed SECRET

DeriveFindingType returned SECRET whenever the code location's has_secret
flag was set, before looking at finding_category. The backend sets that flag
whenever the captured blob contains a secret, not only when the finding is about
the secret.

Measured on a file containing both: a SQL injection at line 14 came back with
has_secret set (a token sat at line 9, inside a snippet starting at line 4),
finding_category: CODE_VULNERABILITY and a CWE-89 title — and was reported as
"type": "SECRET". Anything routing on the type (--group-by, SARIF consumers,
dashboards) mis-sorts it. With --fail-on-secret on by default it also fails a
build over a finding that is not an exposed secret.

Corrected since the first review. The original fix special-cased the
code-vulnerability category and left has_secret deciding for every other
category. Review comment 3 was right that this narrowed the bug instead of fixing
it: a Terraform misconfiguration with a token a few lines away has exactly the
same shape. Any recognised finding_category is the more specific signal and
now wins over the blob-level flag; the flag only rescues a finding whose category
tells us nothing, which would otherwise default to SCA.

SECRET_EXPOSURE — the category the repository scanner actually emits for an
exposed secret — was missing from the category switch entirely, and is now in it.

One table, findingTypeByCategory, is the single source of truth for reading
finding_category, and both readers consult it (comment 5). That matters more than
tidiness: IsSecretExposure and DeriveFindingType had already diverged on
whitespace, so " SECRET " was exempt from the exploitability filter while being
typed SCA. The consistency test added for this went red before any production
change.

3. Duplicate findings at the same location

One subprocess.run(..., shell=True) line comes back as two findings with the same
file, line and start column, both CWE-78, differing only in the prose of the title
and description and by one character of end column. Same defect, same fix.
Reporting both inflates counts, doubles the suppression work to silence one issue,
and makes triage look twice as large as it is.

Identity, corrected since the first review — findings are collapsed on
(file, start line, start column, severity, type, finding category, CWE identifiers). type and finding category were added for comment 4: a
hard-coded credential in a config file is commonly reported both as an exposed
secret and as a misconfiguration at the same file, line and column with equal
severity and no CWE. Without them the two collapse and the exposed secret is as
likely as not to be the one discarded — which would have reintroduced §1 through
the dedupe pass.

Two details the observed data forced:

  • End column is excluded — that is precisely where the duplicates disagree.
  • CWEs are reduced to their identifier, because the backend spells the same one
    differently across findings (CWE-78: … ('OS Command Injection') vs
    CWE-78: … ('Command Injection')). That parsing is no longer a second copy:
    scan.CWENumber is now the one CWE parser, and .armisignore matching in
    repo/matcher.go calls it too (comment 7).

Package findings are never collapsed: several CVEs in one dependency share a
severity and either a manifest location or no location at all, so they would merge
into one and real results would be lost.

A start column or a CWE is also required (Copilot's comment). Both are absent
from some backend payloads, and file+line+severity+type alone is not specific
enough to call two findings the same defect — two different secrets on one line
would collapse into one. The cost is a genuine duplicate left uncollapsed when the
payload carries neither discriminator, which is the safe direction to fail.

Within a group the finding with the lexicographically smallest ID is kept, so the
choice does not depend on the order result pages arrived in, and repeated scans of
one commit report the same finding.

Measured on a real repository: 11 findings → 10, the collapsed pair being the
two CWE-78 reports of one line.

4. A third call site was still dropping exposed secrets

Comment 6 asked for the filter exemption to move into the mechanism, and framed the
risk as hypothetical — "a future third call site". It already existed.
ConvertNormalizedFindings in internal/scan/normalized_findings.go, the shared
conversion path scan sbom uses, still carried its own
!includeNonExploitable && ShouldFilterByExploitability(...). The original three
commits fixed two of the three call sites and missed it.

Reverting the one-line fix to watch the new test fail first gave findings = 0, want 1 — the §1 bug, intact, on that path.

The label check is now unexported so a fourth call site cannot reintroduce it, and
all three sites go through ShouldFilterFinding.

5. --fail-on-secret was a no-op for scan sbom, and SBOM skipped dedupe

Both raised by a second reviewer. Both real; one impact claim was not.

The flag was a no-op. --fail-on-secret is registered on
scanCmd.PersistentFlags() defaulting to true, so scan sbom --help advertises
the gate and it is on unless you opt out — but the command called CheckExit
(severity-only), not CheckExitPolicy. A command whose --help advertises a gate
that defaults to true and does not apply it is broken whichever way the design
question falls; if you would rather scan sbom not carry the flag at all, the
alternative fix is to stop inheriting it, and I will take that instead.

It is unreachable with today's inputs — a CycloneDX document has no code location,
so has_secret never sets. But §2 now classifies on finding_category first, so a
SECRET_EXPOSURE finding with no location types SECRET. The red test showed
exactly that: finding typed SECRET, severity LOW, RunE returned nil.

SBOM skipped dedupe — but "inflates SBOM counts" is false. dedupable()
returns false when the finding carries CVEs, deliberately (§3), and every SBOM
finding is a package CVE. Adding the call changes nothing observable; it is a
placement defect — the mechanism belonged in the shared path, not at two of three
call sites. A second test pins that reasoning in the suite rather than leaving it
in a commit message: two CVEs in one component stay two findings.

6. Declared behaviour change — CVE precedence

This is the one item that changes output for input that behaves correctly on
main, so it is called out rather than buried: a CVE list no longer overrides a
secret, misconfiguration or license category.
It remains the discriminator inside
the dependency space — a package finding naming CVEs is still typed
VULNERABILITY rather than SCA — but LICENSE_COMPLIANCE_RISK + CVEs now types
LICENSE, where main types it VULNERABILITY.

It surfaced because the pre-existing test
TestConvertNormalizedFindings/HasSecret_overrides_CVE_type went red mid-refactor.
It had been passing through the has_secret branch — i.e. for the wrong reason —
so the refactor that gave category precedence removed the accident holding it up.

I have no observed payload of this shape; it is an unreachable edge as far as our
data goes. Making it deliberate rather than incidental is the point of raising it.
Say the word and I will restore CVE precedence over every category — it costs
one condition and no structure.

Verification

Branch is 6 commits off main at f6c12e4: 20 files, +1135/−46.

go build ./..., go vet ./..., gofmt -l clean, golangci-lint run → 0
issues
, go test ./... → 22/22 packages ok, exit 0. 627 subtests in
./internal/scan/... (no main baseline quoted — it was not measured on the same
command, and an unverified comparison is worse than none).

New tests: internal/scan/dedupe_test.go, internal/scan/exploitability_test.go,
internal/scan/finding_type_test.go, internal/output/output_test.go, plus cases
added to internal/scan/normalized_findings_test.go,
internal/cmd/scan_sbom_test.go and internal/scan/repo/repo_test.go.

Each of the three regressions in §2, §4 and §5 was verified by reverting the
production fix and watching the new test fail for the stated reason before
reapplying it.

Two things this PR does not fix

The grade never reaches a consumer. The exploitability label is read off
NormalizedTask.Labels, but model.Finding has no Labels field — so the grade
that decided whether a finding was shown never reaches --format json or SARIF. A
consumer cannot see why something was filtered.

Separately, Validation *FindingValidation (TaintPropagation, Confidence,
Exposure) is already modeled and already wired through normalized_findings.go
— and was null in 32/32 findings we collected, because the server does not
populate it on the ingest pipeline. The plumbing exists and sits inert. Surfacing
the grade, and populating Validation, is what would let a consumer calibrate a
gate rather than trust or distrust the filter wholesale. Happy to open that as an
issue if useful.

Three copies of the conversion loop. repo.go and image.go each carry a
package-local buildScanResult + convertNormalizedFindings beside the shared
scan.BuildScanResult, whose own header comment says the duplication was lifted
for the SBOM work. §4 and §5 are what that duplication cost. After this PR the two
decision points are single-sourced, so semantics cannot diverge — only the loop
bodies can. This is pre-existing main code and out of scope here; I am happy to
open it as a separate PR if you want it.

…secret

`DeriveFindingType` returned SECRET whenever the code location's `has_secret`
flag was set, before looking at `finding_category`. The backend sets that flag
whenever the *blob* it captured contains a secret, not only when the finding is
about the secret, so a SQL injection whose snippet happens to start a few lines
above a hard-coded token arrives with `has_secret` set, `finding_category:
CODE_VULNERABILITY` and a CWE-89 title -- and was reported as
`"type": "SECRET"`. Measured on a scan of a file containing both: the CWE-89
finding at line 14 was typed SECRET because the token sat at line 9, inside the
snippet that starts at line 4. Anything routing on the type (`--group-by`, SARIF
consumers, dashboards) mis-sorts it.

An explicit code-vulnerability category is the more specific signal, so it now
wins over the blob-level flag. `has_secret` still decides for every other
category, which is what kept exposed secrets from falling through to the SCA
default -- and the reason that mattered is fixed properly here too:
SECRET_EXPOSURE, the category the repository scanner actually emits for an
exposed secret, was missing from the category switch.
A single `subprocess.run(..., shell=True)` line comes back as two findings with
the same file, line and start column, both carrying CWE-78, differing only in
the prose of their title and description and by one character of end column.
They are the same defect with the same proposed fix. Reporting both inflates
finding counts, doubles the suppression work needed to silence one issue, and
makes triage look twice as large as it is.

Findings are now collapsed on (file, start line, start column, severity, CWE
identifiers). Two details the observed data forced:

- End column is excluded, because that is precisely where the duplicates
  disagree.
- CWEs are reduced to their identifier, because the backend spells the same one
  differently across findings ("CWE-78: ... ('OS Command Injection')" and
  "CWE-78: ... ('Command Injection')").

Package findings are never collapsed: several CVEs in one dependency share a
severity and either a manifest location or no location at all, so they would
merge into one and real results would be lost. Only findings with a file, a line
and no CVEs are candidates.

Within a group the finding with the lexicographically smallest ID is kept, so
the choice does not depend on the order the result pages arrived in and repeated
scans of one commit report the same finding.

Measured on a real repository: 11 findings to 10, with the collapsed pair being
the two CWE-78 reports of one line.
A repository whose only problem was two hard-coded AWS credentials scanned
completely clean: `total: 0`, `filtered_non_exploitable: 2`, exit 0. The
exploitability filter graded both secrets low/medium and dropped them before
they reached the report.

Reachability grading does not apply to a literal secret. It is already disclosed
to everyone who can read the repository; there is no path to reason about. So
`IsSecretExposure` now exempts secret findings from the filter, in both the
repository and the image scanner. On the same fixture that reported nothing:
2 findings, both HIGH, exit 1.

The second half of the same problem is severity. Where the backend does surface
a secret, it can arrive as `severity: INFO` with no CWE, so under
`--fail-on HIGH,CRITICAL` -- the setting every CI example uses -- a credential
committed to source is still a green build. `--fail-on-secret`, on by default,
exits non-zero when a secret is exposed whatever severity was assigned, and
explains on stderr why the scan failed when no finding met the configured
threshold. `--fail-on-secret=false` restores the old behaviour, and a secret
suppressed via .armisignore is ignored as before, so the escape hatch is
unchanged.

`ShouldFail` and `CheckExit` keep their severity-only meaning for existing
callers; the policy lives in the new `ExitPolicy`, which `scan repo` and
`scan image` pass.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new deduplication can collapse distinct findings when start columns/CWEs are missing, which risks dropping real results.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR tightens scan-gating correctness for scan repo / scan image by ensuring secret exposures are not dropped by exploitability filtering, correcting finding type derivation (so code vulns aren’t mislabeled as secrets), and collapsing backend-duplicated findings so summaries and reports reflect what users actually see.

Changes:

  • Exempt secret exposure findings from exploitability-based filtering in both repo and image scanners.
  • Fix DeriveFindingType precedence and add missing SECRET_EXPOSURE category handling.
  • Add cross-scanner deduplication of findings (by stable location/severity/CWE identity) before summary construction, plus new tests; introduce a new ExitPolicy that can fail scans on secrets regardless of severity.
File summaries
File Description
internal/scan/repo/repo.go Deduplicates findings pre-summary; exempts secret exposures from exploitability filtering.
internal/scan/image/image.go Mirrors repo scanner behavior for dedupe + secret-exposure filtering exemption.
internal/scan/finding_type.go Adjusts type derivation precedence; recognizes SECRET_EXPOSURE explicitly.
internal/scan/finding_type_test.go Adds coverage for updated type precedence and SECRET_EXPOSURE.
internal/scan/exploitability.go Adds IsSecretExposure helper for category-based secret exposure detection.
internal/scan/exploitability_test.go Tests IsSecretExposure behavior across category shapes/cases.
internal/scan/dedupe.go Introduces backend-duplicate collapsing logic for findings.
internal/scan/dedupe_test.go Validates dedupe keying, stability, and ordering preservation.
internal/output/output.go Adds ExitPolicy, ShouldFailPolicy, and CheckExitPolicy with secret-fail behavior.
internal/output/output_test.go Tests secret-fail policy behavior and stderr explanation.
internal/cmd/scan.go Adds --fail-on-secret flag (default true).
internal/cmd/scan_repo.go Uses CheckExitPolicy with the new policy (including secret-fail).
internal/cmd/scan_image.go Uses CheckExitPolicy with the new policy (including secret-fail).
Review details
  • Files reviewed: 13/13 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/scan/dedupe.go
Comment on lines +79 to +81
func dedupable(f model.Finding) bool {
return f.File != "" && f.StartLine > 0 && len(f.CVEs) == 0
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9d3c42a. A discriminator is now required: dedupable returns false unless the finding has a start column or at least one CWE, so file+line+severity+type alone can never collapse two findings.

func dedupable(f model.Finding) bool {
	if f.File == "" || f.StartLine <= 0 || len(f.CVEs) != 0 {
		return false
	}
	return f.StartColumn > 0 || len(f.CWEs) > 0
}

The trade-off is stated in the doc comment rather than hidden: when a payload carries neither a column nor a CWE, a genuine duplicate is now left uncollapsed. That is the safe direction — an uncollapsed duplicate is noise, a wrongly collapsed pair loses a finding.

Two cases pin it in TestDeduplicateFindings: "no column and no CWE is too unspecific to collapse" and "no column but a shared CWE still collapses" (the CWE alone is enough).

Your "two different secrets on one line" example is also covered from the other side now — see the reply on the dedupeKey thread, where Type and FindingCategory joined the identity.

Comment thread internal/output/output.go
Comment on lines +77 to +83
// FailOnSecret fails the scan when a secret is exposed in the scanned source,
// whatever severity the backend assigned it. An exposed secret arrives as
// severity INFO with no CWE unless --include-non-exploitable is passed, so
// under the usual --fail-on HIGH,CRITICAL a credential committed to source is
// a passing scan. Reachability grading has no bearing on a literal secret:
// it is already disclosed to everyone who can read the repository.
FailOnSecret bool

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 93681c2. The comment was describing the pre-PR behaviour. It now says what the flag does and does not change:

Reachability grading has no bearing on a literal secret — it is already disclosed to everyone who can read the repository — so exposed secrets are exempt from the exploitability filter and reach this policy whether or not --include-non-exploitable was passed. That flag changes which graded findings appear, not whether secrets do.

TestShouldFilterFinding/an exposed secret graded low is never filtered asserts the behaviour the comment now claims.

@yiftach-armis yiftach-armis left a comment •

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.

Thank you for finding this!

Comment thread internal/scan/finding_type.go Outdated
// mis-sorts it everywhere the type is routed on (--group-by, SARIF consumers,
// dashboards). An explicit code-vulnerability category is the more specific
// signal, so it wins over the blob-level flag.
if hasSecret && category != "CODE_VULNERABILITY" && category != "VULNERABILITY" {

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.

The has_secret override is only suppressed for finding_category CODE_VULNERABILITY/VULNERABILITY, so every other category (INFRA_AS_CODE/MISCONFIG, LICENSE_COMPLIANCE_RISK/LICENSE, CODE_PACKAGE_VULNERABILITY) is still forced to type SECRET whenever has_secret is true — reproducing the bug this PR claims to fix, just narrower.

Failure scenario: a Terraform/K8s misconfiguration finding (finding_category=INFRA_AS_CODE) whose captured code blob happens to contain an unrelated token a few lines away arrives with has_secret=true; DeriveFindingType still returns FindingTypeSecret instead of FindingTypeMisconfig. Combined with the new default-on --fail-on-secret, this can fail a build over a finding that isn't actually an exposed secret.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right, and the narrowing was the wrong shape of fix. Corrected in 0f63f05.

DeriveFindingType now classifies on finding_category first. Any recognised category wins; has_secret only rescues a finding whose category tells us nothing (which would otherwise default to SCA):

if categoryKnown {
	if hasCVEs && categoryType == model.FindingTypeSCA {
		return model.FindingTypeVulnerability
	}
	return categoryType
}
if hasSecret {
	return model.FindingTypeSecret
}

So your Terraform case — INFRA_AS_CODE + an unrelated token in the blob — now types MISCONFIG, and with --fail-on-secret on by default it no longer fails the build. Four cases cover the categories the old switch missed: "infra-as-code category beats a secret in the blob", "license category beats a secret in the blob", "package vulnerability category beats a secret in the blob", and "secret still overrides an unrecognised category" for the rescue path.

One behaviour change to flag explicitly, because it goes slightly beyond the bug. The CVE list is no longer a classification of its own — it stays the discriminator inside the dependency space (a package finding naming CVEs is a vulnerability, not SCA) but no longer overrides a secret, misconfig or license category. LICENSE_COMPLIANCE_RISK + CVEs → LICENSE where main gives VULNERABILITY.

That surfaced because the pre-existing TestConvertNormalizedFindings/HasSecret_overrides_CVE_type went red mid-refactor: it had been passing through the has_secret branch, i.e. for the wrong reason. I'd rather have this as a deliberate call you can veto than as an incidental side effect — say so and I'll restore the CVE precedence for non-code categories.

Comment thread internal/scan/dedupe.go

// dedupeKey builds the identity used by DeduplicateFindings. CWEs are reduced to
// their identifiers and sorted, so two orderings of the same set produce one key.
func dedupeKey(f model.Finding) string {

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.

dedupeKey identifies findings by (file, start line, start column, severity, CWE set) but omits Type/FindingCategory, so two genuinely different findings of different types at the same location with equal (often empty) severity/CWE collapse into one.

Failure scenario: a hard-coded credential in a config file is commonly reported both as a SECRET_EXPOSURE finding and as a MISCONFIG finding ("plaintext credential in manifest") at the same file/line/column, both e.g. HIGH severity with no CWE. DeduplicateFindings produces an identical key for both and keeps only the lexicographically-smaller ID — which can mean the secret finding itself is the one discarded, undermining this PR's stated goal of never dropping exposed secrets.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9d3c42a. dedupeKey now includes Type and the normalized FindingCategory:

b.WriteString(string(f.Severity))
b.WriteByte(0)
b.WriteString(string(f.Type))
b.WriteByte(0)
b.WriteString(normalizeFindingCategory(f.FindingCategory))

Category goes through the same normalizeFindingCategory the type table uses, so secret_exposure and SECRET_EXPOSURE don't key apart.

Your exact scenario is now a test — TestDeduplicateFindingsKeepsDistinctTypes/secret and misconfig at one location both survive: same file, line and column, both HIGH, no CWE, one SECRET_EXPOSURE and one MISCONFIG, two findings out. Plus "same type, different finding_category both survive" for the case where only the category differs.

The doc comment on DeduplicateFindings now carries your reasoning about why, so a future reader doesn't drop the fields as redundant.

Comment thread internal/scan/exploitability.go Outdated

// secretCategories are the finding_category values that mean "a secret is exposed
// in the scanned source".
var secretCategories = map[string]struct{}{

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.

secretCategories (SECRET, SECRET_EXPOSURE) duplicates the equivalent switch case in internal/scan/finding_type.go independently, with no shared source of truth.

Failure scenario: if the backend adds a third category spelling for secrets and only one of the two lists is updated, a finding could be exempted from exploitability filtering but typed as something else (or vice versa), silently reintroducing the drop-a-secret bug for the new category.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0f63f05, and the divergence you predicted had already happened — the test I wrote for this went red before I changed any production code.

IsSecretExposure trimmed whitespace and DeriveFindingType didn't, so a category of " SECRET " was exempt from the exploitability filter and typed SCA. Exactly the failure mode you described, in the two lists as they stood.

There is now one table:

var findingTypeByCategory = map[string]model.FindingType{
	"CODE_VULNERABILITY": model.FindingTypeVulnerability,
	...
	"SECRET":          model.FindingTypeSecret,
	"SECRET_EXPOSURE": model.FindingTypeSecret,
	...
}

FindingTypeForCategory is the only reader. IsSecretExposure is now derived rather than parallel — it asks the table and checks for FindingTypeSecret — so adding a third spelling to the map updates both answers by construction. TestSecretCategoryClassificationIsShared walks both functions over the same inputs (including the whitespace and case variants) and asserts they agree.

Comment thread internal/scan/repo/repo.go Outdated
// exemption two hard-coded credentials in a repository with no web entry
// point are graded low exploitability, dropped here, and the scan reports a
// clean repository.
if !includeNonExploitable &&

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.

The !scan.IsSecretExposure(nf) && scan.ShouldFilterByExploitability(...) exemption is duplicated verbatim in repo.go and image.go instead of being pushed into ShouldFilterByExploitability itself.

Failure scenario: a future third call site (a new scan type, or exploitability filtering added to e.g. scan sbom) copies the old single-condition pattern and forgets the IsSecretExposure guard, silently reintroducing the "secrets dropped by the exploitability filter" bug this PR fixes, because the exemption lives at each call site rather than inside the mechanism it exempts from.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0f63f05. The exemption is inside the mechanism now, and the call sites can't spell it wrong because they no longer spell it at all:

func ShouldFilterFinding(nf model.NormalizedFinding, includeNonExploitable bool) bool {
	if includeNonExploitable {
		return false
	}
	if IsSecretExposure(nf) {
		return false
	}
	return shouldFilterByExploitability(nf.NormalizedTask.Labels)
}

shouldFilterByExploitability is unexported, so ShouldFilterFinding is the only way in.

Worth knowing: your hypothetical third call site already existed. ConvertNormalizedFindings in internal/scan/normalized_findings.go — the shared path scan sbom goes through — still had the bare !includeNonExploitable && ShouldFilterByExploitability(...). My own PR had fixed two of three call sites and missed it, which is the argument for your comment better than anything I could write. I reverted the one-line fix to watch the new test fail (findings = 0, want 1) before reapplying it.

That found a second one too — scan sbom was advertising --fail-on-secret without applying it. Both are in 984b13d; I've left a summary comment on the PR rather than burying it in this thread.

Comment thread internal/scan/dedupe.go
// "CWE-78: ... ('Command Injection')" -- so the descriptive tail cannot be part of
// the identity. A string with no recognisable identifier is returned unchanged
// rather than dropped, so it still distinguishes findings.
func cweIdentifier(cwe string) string {

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.

cweIdentifier's anchored regex (^(?i:cwe)-[0-9]+$) duplicates internal/scan/repo/matcher.go's prefix-matching cweIntPattern (^CWE-(\d+)) with different semantics, so a CWE string format matcher.go already tolerates for suppression can fail to normalize here.

Failure scenario: a CWE string like "CWE-78 - OS Command Injection" (dash instead of colon separator) has no colon, so cweIdentifier falls through to the full-string anchor, which fails, and the raw string is returned unchanged. Two real duplicate findings using that format then get different dedupe keys and fail to collapse, while a maintainer who later fixes CWE-parsing in matcher.go has no reason to look at dedupe.go.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9d3c42a. scan.CWENumber (internal/scan/cwe.go:25) is now the single parser, and both callers use it — cweIdentifier here and matcher.go:65 for .armisignore matching:

func cweIdentifier(cwe string) string {
	if number, ok := CWENumber(cwe); ok {
		return "CWE-" + number
	}
	return strings.TrimSpace(cwe)
}

Your "CWE-78 - OS Command Injection" case is a test: "dash-separated CWE spelling normalizes like matcher.go", asserting it collapses with "CWE-78: OS Command Injection". Also "bare numeric CWE normalizes like matcher.go".

Unparseable strings are still returned trimmed rather than dropped, so an unrecognised spelling keeps distinguishing findings instead of merging them all under one empty key.

Comment thread internal/scan/dedupe.go Outdated
}

type group struct {
index int // position of the kept finding in the output

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.

Minor: group.index is assigned on every insertion but never read anywhere in the file. Not a runtime bug, but a future reader may assume index drives output ordering when the order slice's append order actually does — dead field adds confusion for no benefit.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed in 9d3c42a. group is now just what it needs to be:

// Output order is the append order of the order slice; a group only ever needs
// to know which finding it is currently keeping.
type group struct {
	keptID  string
	finding model.Finding
}

The comment states what the field would have implied, so the next reader doesn't have to reconstruct it.

closerforever and others added 3 commits September 9, 2026 10:40
…for the filter exemption

Addresses three review comments on ArmisSecurity#313, all of the same shape: the fixes sat at
the call site rather than in the mechanism.

1. `has_secret` was only suppressed for CODE_VULNERABILITY/VULNERABILITY, so
   INFRA_AS_CODE, LICENSE_COMPLIANCE_RISK and CODE_PACKAGE_VULNERABILITY were
   still forced to type SECRET whenever the captured blob happened to contain a
   token -- the reported bug, just narrower. With `--fail-on-secret` on by
   default that fails a build over a Terraform misconfiguration.

   DeriveFindingType now classifies on finding_category first: any recognised
   category wins over the blob-level flag, and the flag only rescues a finding
   whose category tells us nothing. The CVE list keeps its role as the
   discriminator inside the dependency space (a package category naming CVEs is
   a vulnerability, not SCA) but no longer overrides a secret, misconfig or
   license classification -- which is what made the pre-existing
   "HasSecret overrides CVE type" case pass for the wrong reason.

2. `secretCategories` in exploitability.go duplicated the category switch in
   finding_type.go with no shared source of truth. Both now read one
   `findingTypeByCategory` table via `FindingTypeForCategory`, and a new test
   asserts IsSecretExposure and DeriveFindingType agree on every category, so
   the two cannot diverge when the backend adds a spelling. That test
   immediately caught a live divergence: IsSecretExposure trimmed whitespace
   and DeriveFindingType did not.

3. The `!IsSecretExposure(nf) && ShouldFilterByExploitability(...)` exemption
   was spelled out verbatim in repo.go and image.go. It now lives inside the
   mechanism as `ShouldFilterFinding(nf, includeNonExploitable)`; the
   label-level check is unexported.

   The predicted failure was already real: `scan.ConvertNormalizedFindings` in
   normalized_findings.go -- the shared path `scan sbom` uses -- was a third
   call site still carrying the old single-condition filter, so an exposed
   secret graded low exploitability was silently dropped there while repo and
   image scans exempted it. Fixed by the same call, with a regression test.

Also corrects the ExitPolicy doc comment: secrets now reach the policy whether
or not --include-non-exploitable was passed (Copilot).

go build, go vet, gofmt, golangci-lint and go test ./... all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e CWE parser

Addresses the remaining review comments on ArmisSecurity#313.

`dedupeKey` identified a finding by (file, start line, start column, severity,
CWE set) and omitted Type/FindingCategory. A hard-coded credential in a config
file is commonly reported both as SECRET_EXPOSURE and as MISCONFIG at the same
file, line and column, both HIGH with no CWE: the two produced an identical key,
only the lexicographically smaller ID survived, and that could be the
misconfiguration -- discarding the exposed secret this PR exists to protect.
Type and normalized finding_category are now part of the identity.

`dedupable` additionally requires a start column or a CWE. Both are absent from
some payloads, and file+line+severity+type alone is not specific enough to call
two findings the same defect -- two different secrets on one line would collapse
(Copilot). The cost is a genuine duplicate left uncollapsed when the payload
carries neither discriminator, which is the safe direction.

`cweIdentifier`'s anchored regex duplicated matcher.go's prefix-matching
`cweIntPattern` with different semantics, so "CWE-78 - OS Command Injection" --
a spelling .armisignore suppression already tolerates -- failed to normalize
here and stopped two real duplicates from collapsing. Both now call
`scan.CWENumber`, the single CWE parser, which accepts every spelling the
backend emits ("CWE-78", "cwe-78", "CWE-78: ...", "CWE-78 - ...", "78").
matcher.go's behaviour is unchanged.

Removed the dead `group.index` field, which read as if it drove output ordering
when the order slice's append order does.

go build, go vet, gofmt, golangci-lint and go test ./... all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two call sites still bypassed mechanisms this branch introduced.

`scan sbom` gated on severity alone. --fail-on-secret is a persistent flag on
`scan` defaulting to true, so `scan sbom --help` advertises the gate and it is
on unless the user opts out -- but the command called CheckExit, not
CheckExitPolicy, so a secret graded below the --fail-on threshold exited 0.
Unreachable with today's inputs (a CycloneDX document carries no code location,
so the backend has nothing to find a secret in), which is why a test now holds
it: DeriveFindingType classifies on finding_category, so the day the backend
attaches SECRET_EXPOSURE to an SBOM finding the gate has to fire here as it does
for repo and image.

The dedupe pass lived in the repo and image drivers' own buildScanResult, so a
scan type routed through the shared BuildScanResult skipped it. Moved into the
shared helper. No visible change for `scan sbom` -- dedupable() excludes
CVE-bearing package findings, because several CVEs in one dependency
legitimately share a manifest location -- and a test pins both halves of that:
duplicated code findings collapse, two CVEs in one component do not.

Same shape as the fix for the third exploitability call site earlier on this
branch: the semantics were already single-sourced, the call sites had not all
been moved onto them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@closerforever

Copy link
Copy Markdown
Author

All eight review comments are addressed — three commits pushed on top of the original three, replies in each thread. @yiftach-armis, the three refactors you asked for turned up two real bugs the call-site fixes had been hiding, so they were worth more than tidiness:

# comment commit
1 dedupe key less specific than documented (no column, no CWEs) 9d3c42a
2 stale ExitPolicy comment re --include-non-exploitable 93681c2
3 has_secret override only narrowed, not fixed 0f63f05
4 dedupeKey omits Type / FindingCategory 9d3c42a
5 secretCategories duplicates the type switch 0f63f05
6 exemption duplicated at call sites, not in the mechanism 0f63f05
7 cweIdentifier duplicates matcher.go's CWE parsing 9d3c42a
8 dead group.index field 9d3c42a

Two things the refactors exposed, in the threads but worth surfacing here: comment 6's hypothetical third call site already existed (ConvertNormalizedFindings, the shared path scan sbom uses — this PR had fixed two of three), and comment 5's consistency test went red before any production change, because the two secret-category readers had already diverged on whitespace trimming.

Two more call sites, volunteered

A second reviewer pass over the branch found the same class of defect twice more. Both are fixed in 984b13d, and both are the reason comment 6 was the right call.

1. --fail-on-secret was a no-op for scan sbom — a real defect. internal/cmd/scan.go:170 registers the flag on scanCmd.PersistentFlags() defaulting to true, so scan sbom --help advertises the gate and it is on unless you opt out — but the command called output.CheckExit (severity-only), not CheckExitPolicy. A command that advertises a gate, on by default, and does not apply it is a defect whichever way the design question falls.

It is unreachable with today's inputs — a CycloneDX document carries no code location, so loc.HasSecret never sets. But the comment-3 refactor makes DeriveFindingType classify on finding_category first, so a SECRET_EXPOSURE finding with no location now types SECRET. A red test confirmed it before the fix: finding typed SECRET, severity LOW, RunE returned nil. Now TestScanSBOMRunE_FailsOnExposedSecret.

2. The SBOM path skipped the dedupe pass — placement, not behaviour. scan repo and scan image call DeduplicateFindings before building the summary; the shared BuildScanResult did not. Moved in, so the next scan type routed through the shared path inherits it.

I want to be precise that this one changes nothing observable, rather than claim a fix it isn't: dedupable() returns false when len(f.CVEs) != 0, deliberately, because several CVEs in one dependency share a manifest location. Every SBOM finding is a package CVE, so no SBOM count moves. TestBuildScanResultKeepsDistinctCVEsInOneComponent pins exactly that — two CVEs in one component stay two findings — so the reason it is safe lives in the suite rather than only in a commit message.

One thing I found and deliberately left alone

scan repo and scan image each carry a package-local buildScanResult + convertNormalizedFindings (repo.go:686, image.go:442) alongside the shared scan.BuildScanResult, whose own header comment says the duplication was lifted for the SBOM work. Three copies of the conversion loop.

After this PR the two decision points are single-sourced — all three delegate to ShouldFilterFinding and DeriveFindingType — so the semantics you were worried about in comments 5 and 6 can no longer diverge. The loop bodies still can. That's pre-existing main code and a separate PR; happy to open it if you want it, but it didn't belong in this one.

Verification

go build, go vet, gofmt -l clean, golangci-lint run → 0 issues, go test ./... → 22/22 packages ok, 627 subtests under ./internal/scan/.... Each behavioural fix above was watched failing before it was applied (comments 2 and 8 are a comment and a dead field, so they have no red state).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants