Repository navigation
fix(scan): stop dropping exposed secrets, collapse duplicates, and type injection findings correctly - #313
Conversation
…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.
There was a problem hiding this comment.
🟡 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
DeriveFindingTypeprecedence and add missingSECRET_EXPOSUREcategory handling. - Add cross-scanner deduplication of findings (by stable location/severity/CWE identity) before summary construction, plus new tests; introduce a new
ExitPolicythat 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.
| func dedupable(f model.Finding) bool { | ||
| return f.File != "" && f.StartLine > 0 && len(f.CVEs) == 0 | ||
| } |
There was a problem hiding this comment.
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.
| // 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 |
There was a problem hiding this comment.
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-exploitablewas 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.
| // 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" { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
|
||
| // 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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
|
||
| // secretCategories are the finding_category values that mean "a secret is exposed | ||
| // in the scanned source". | ||
| var secretCategories = map[string]struct{}{ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // 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 && |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // "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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| type group struct { | ||
| index int // position of the kept finding in the output |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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>
|
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:
Two things the refactors exposed, in the threads but worth surfacing here: comment 6's hypothetical third call site already existed ( Two more call sites, volunteeredA second reviewer pass over the branch found the same class of defect twice more. Both are fixed in 1. It is unreachable with today's inputs — a CycloneDX document carries no code location, so 2. The SBOM path skipped the dedupe pass — placement, not behaviour. I want to be precise that this one changes nothing observable, rather than claim a fix it isn't: One thing I found and deliberately left alone
After this PR the two decision points are single-sourced — all three delegate to Verification
|
What this is
Three gating-correctness fixes in
scan repo/scan image, found whileevaluating 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-cliv1.22.0 and the live API. They areindependent 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.
1. Exposed secrets were dropped by the exploitability filter
Fixture containing nothing but two hard-coded AWS keys, scanned on
main: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 thelabel check (
ShouldFilterByExploitability) plus the secret-exposure test areits internals.
The second half is severity. Where the backend does surface a secret, it can
arrive as
severity: INFOwith no CWE — so under--fail-on HIGH,CRITICAL, thesetting 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 asecret is exposed whatever severity was assigned, and explains on stderr why the
scan failed when no finding met the configured threshold.
--fail-on-secret=falserestores the old behaviour, and a secret suppressed via
.armisignoreis ignoredexactly as before, so the escape hatch is unchanged.
ShouldFailandCheckExitkeep their severity-only meaning for existing callers;the new policy lives in
ExitPolicy, whichscan repo,scan imageand — as of§5 —
scan sbompass.2. An injection finding was typed
SECRETDeriveFindingTypereturnedSECRETwhenever the code location'shas_secretflag was set, before looking at
finding_category. The backend sets that flagwhenever 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_secretset (a token sat at line 9, inside a snippet starting at line 4),finding_category: CODE_VULNERABILITYand 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-secreton by default it also fails abuild 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_secretdeciding for every othercategory. 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_categoryis the more specific signal andnow 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 anexposed secret — was missing from the category switch entirely, and is now in it.
One table,
findingTypeByCategory, is the single source of truth for readingfinding_category, and both readers consult it (comment 5). That matters more thantidiness:
IsSecretExposureandDeriveFindingTypehad already diverged onwhitespace, so
" SECRET "was exempt from the exploitability filter while beingtyped
SCA. The consistency test added for this went red before any productionchange.
3. Duplicate findings at the same location
One
subprocess.run(..., shell=True)line comes back as two findings with the samefile, 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).typeandfinding categorywere added for comment 4: ahard-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:
differently across findings (
CWE-78: … ('OS Command Injection')vsCWE-78: … ('Command Injection')). That parsing is no longer a second copy:scan.CWENumberis now the one CWE parser, and.armisignorematching inrepo/matcher.gocalls 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.
ConvertNormalizedFindingsininternal/scan/normalized_findings.go, the sharedconversion path
scan sbomuses, still carried its own!includeNonExploitable && ShouldFilterByExploitability(...). The original threecommits 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-secretwas a no-op forscan sbom, and SBOM skipped dedupeBoth raised by a second reviewer. Both real; one impact claim was not.
The flag was a no-op.
--fail-on-secretis registered onscanCmd.PersistentFlags()defaulting totrue, soscan sbom --helpadvertisesthe gate and it is on unless you opt out — but the command called
CheckExit(severity-only), not
CheckExitPolicy. A command whose--helpadvertises a gatethat defaults to true and does not apply it is broken whichever way the design
question falls; if you would rather
scan sbomnot carry the flag at all, thealternative 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_secretnever sets. But §2 now classifies onfinding_categoryfirst, so aSECRET_EXPOSUREfinding with no location typesSECRET. The red test showedexactly that: finding typed
SECRET, severityLOW,RunEreturnednil.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 asecret, misconfiguration or license category. It remains the discriminator inside
the dependency space — a package finding naming CVEs is still typed
VULNERABILITYrather thanSCA— butLICENSE_COMPLIANCE_RISK+ CVEs now typesLICENSE, wheremaintypes itVULNERABILITY.It surfaced because the pre-existing test
TestConvertNormalizedFindings/HasSecret_overrides_CVE_typewent red mid-refactor.It had been passing through the
has_secretbranch — 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
mainatf6c12e4: 20 files, +1135/−46.go build ./...,go vet ./...,gofmt -lclean,golangci-lint run→ 0issues,
go test ./...→ 22/22 packagesok, exit 0.627subtests in./internal/scan/...(nomainbaseline quoted — it was not measured on the samecommand, 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 casesadded to
internal/scan/normalized_findings_test.go,internal/cmd/scan_sbom_test.goandinternal/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, butmodel.Findinghas noLabelsfield — so the gradethat decided whether a finding was shown never reaches
--format jsonor SARIF. Aconsumer cannot see why something was filtered.
Separately,
Validation *FindingValidation(TaintPropagation,Confidence,Exposure) is already modeled and already wired throughnormalized_findings.go— and was
nullin 32/32 findings we collected, because the server does notpopulate it on the ingest pipeline. The plumbing exists and sits inert. Surfacing
the grade, and populating
Validation, is what would let a consumer calibrate agate rather than trust or distrust the filter wholesale. Happy to open that as an
issue if useful.
Three copies of the conversion loop.
repo.goandimage.goeach carry apackage-local
buildScanResult+convertNormalizedFindingsbeside the sharedscan.BuildScanResult, whose own header comment says the duplication was liftedfor 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
maincode and out of scope here; I am happy toopen it as a separate PR if you want it.