This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new eb90d72 feat(security): credit obvious bots as CVE type:"tool", not
skipped finders (#290)
eb90d72 is described below
commit eb90d72eef7cf6d09ab4e8cd7402a50ec229a29c
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon May 25 21:53:54 2026 +0200
feat(security): credit obvious bots as CVE type:"tool", not skipped finders
(#290)
CVE 5.x's `credits[]` schema has a dedicated `type: "tool"` value
exactly for automation that surfaced a vulnerability -- scanners,
AI agents, GitHub bots. Until this commit, the framework's bot
detection treated all such candidates the same way: skip the
credit row entirely. That under-credits real tool contributions
to the CVE record.
This change splits the rule by which credit field is involved:
- **Finder side (`Reporter credited as`)**: detected bot/AI ->
included in the field; the CVE JSON generator emits the row
with `type: "tool"` instead of the default `type: "finder"`.
Scanners and agents get the credit they deserve, just under
the right schema category.
- **Remediation-developer side (`Remediation developer`)**:
detected bot -> still skipped. There is no remediation-side
equivalent of `type: "tool"`; a Dependabot dep-bump is the
automation doing what humans configured it to do and does not
warrant a credit row.
The clarification Gmail draft for direct-reporter mode is
reframed accordingly: instead of "is `<bot>` really how you want
to be credited?", it now asks "we've credited `<bot>` as a tool;
if a human was behind it who should also be credited as finder,
please reply with their name". The tool credit is the floor; the
human finder credit is an additive ask.
Detection rules (`[bot]` suffix, known-bot/automation-name list,
`*-bot` / `*-ai` / `*-agent` / `*-gpt` / `*scanner*` / `*automat*`
patterns, noreply/relay senders) are unchanged. The single source
of truth for `who counts as a bot` stays in
`tools/vulnogram/bot-credits-policy.md`; the same rule is now also
implemented in `generate_cve_json.is_bot_credit()` so the
generator can route per-row.
Generator change:
- `tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py`:
new `is_bot_credit(name)` predicate (mirrors the policy doc's
three handle-shaped rules: `[bot]` suffix, known-name whole-word
match, regex pattern set). New `TOOL_CREDIT_TYPE = "tool"`
constant. `build_credits()` runs `is_bot_credit()` on every line
of *Reporter credited as* and emits `type: "tool"` when it
matches, falling back to `type: "finder"` otherwise. The
remediation-developer side is unchanged -- it still emits
`type: "remediation developer"` for every row, and the
upstream-skill skip rule keeps bots out of that field.
- Module docstring's `credits[]` summary updated to name the
new tool routing.
Tests:
- New `TestIsBotCredit` (16 cases): GitHub `[bot]` suffix
variants, known-name list (case-insensitive, free-form string),
pattern handles (`*-bot`, `securitybot`, `scan-ai`, etc.),
human-name false-positive guards (`Alice Smith`, `Jane Doe`,
`Joe Bot` -- space breaks the word boundary so the pattern
doesn't fire on the human name), empty / whitespace.
- New `TestBuildCreditsBotTypeAssignment` (6 cases): plain human
-> finder, GitHub bot suffix -> tool, known bot name -> tool,
pattern handle -> tool, mixed-row field -> per-row type
assignment, remediation-developer side unaffected by the new
routing (an explicit regression test for the asymmetric scope
decision).
- 229 total tests pass (210 existing + 19 new).
Policy doc + skills:
- `tools/vulnogram/bot-credits-policy.md`: rewritten *Why*,
*Default behaviour*, *Where the rule applies / does NOT apply*,
and *Worked examples* to reflect the asymmetric finder/
remediation routing. Detection rules unchanged. New worked
example showing a mixed-credit field through the generator
(Alice Smith -> finder; Dependabot -> tool).
- `security-issue-import` SKILL.md: ASF-relay credit extraction
+ Reporter-credited-as field rows updated -- bot detection now
produces a `credited as tool: <handle>` proposal entry and
keeps the handle in the field. The relay/noreply-sender
suppression for routing artefacts is preserved.
- `security-issue-import-from-md` SKILL.md: reporter-line
extraction routed through tool-credit instead of skip.
- `security-issue-sync` SKILL.md: reporter-credit mining from
email replies routed through tool-credit; the clarification
draft framing updated to ask about an additional human finder
rather than replacing the bot credit. The remediation-side
PR-author-append rule (Step 2b) is unchanged.
- `security-issue-deduplicate` SKILL.md: explicit per-side
behaviour -- finder-side rows from either tracker propagate as
tool credits; remediation-side rows still skip.
Generated-by: Claude Code (Opus 4.7)
---
.claude/skills/security-issue-deduplicate/SKILL.md | 40 ++-
.../skills/security-issue-import-from-md/SKILL.md | 2 +-
.claude/skills/security-issue-import/SKILL.md | 4 +-
.claude/skills/security-issue-sync/SKILL.md | 20 +-
tools/vulnogram/bot-credits-policy.md | 347 ++++++++++++++-------
.../src/generate_cve_json/cve_json.py | 111 ++++++-
.../tests/test_generate_cve_json.py | 135 +++++++-
7 files changed, 508 insertions(+), 151 deletions(-)
diff --git a/.claude/skills/security-issue-deduplicate/SKILL.md
b/.claude/skills/security-issue-deduplicate/SKILL.md
index 5163d0f..6f77510 100644
--- a/.claude/skills/security-issue-deduplicate/SKILL.md
+++ b/.claude/skills/security-issue-deduplicate/SKILL.md
@@ -304,20 +304,34 @@ confirmed, or the placeholder form when unconfirmed; the
merge
does not silently re-synthesize credits)
**Apply the [bot/AI credit
policy](../../../tools/vulnogram/bot-credits-policy.md)
-when consolidating.** If either tracker carries a credit line that
-matches the bot detection rule (`*[bot]` suffix, known-bot list,
+when consolidating.** If either tracker carries a credit line on
+the **finder side** (*Reporter credited as*) that matches the bot
+detection rule (`*[bot]` suffix, known-bot list,
`*-bot`/`*-ai`/`*-agent`/`*-gpt` / `*scanner*` / `*automat*`
-suffix patterns, automation-name list), **do not** propagate that
-line into the kept tracker's *Reporter credited as* field. Instead,
-surface in the proposal *"skipped credit (during merge): `<line>`
-(matches bot policy — `<rule>`)"* with the source tracker number.
-If the drop tracker has an inbound reporter thread to reply on,
-also propose the policy's *clarification-reply* Gmail draft asking
-whether the bot/AI handle is the intended credit. The user can
-override per the policy doc. Manual credits that a human
-security-team member typed in (visible in the issue timeline) are
-always preserved verbatim — the filter only fires on credit lines
-that were auto-extracted upstream.
+suffix patterns, automation-name list), propagate the line into
+the kept tracker's *Reporter credited as* field unchanged — the
+CVE JSON generator emits it with `type: "tool"` per the policy's
+finder-side rule. Surface in the proposal *"credited as tool
+(during merge): `<line>` (matches bot policy — `<rule>`)"* with
+the source tracker number so the user can see which rows are
+being routed as tools. If the drop tracker has an inbound
+reporter thread to reply on, also propose the policy's
+*clarification-reply* Gmail draft asking whether a human behind
+the bot/AI handle should be **additionally** credited as finder.
+The user can override per the policy doc.
+
+For the **remediation-developer side**, the dedup still applies
+the original *skip* rule: a bot-matching line in either tracker's
+*Remediation developer* field is dropped from the merge result
+(no `type: "tool"` mapping exists for remediation-developer
+credits — see the policy doc). Surface *"skipped credit
+(during merge): `<line>` (matches bot policy — `<rule>`)"* for
+remediation-side rows.
+
+Manual credits that a human security-team member typed in
+(visible in the issue timeline) are always preserved verbatim
+on both sides — the filter only fires on credit lines that were
+auto-extracted upstream.
```markdown
### PR with the fix
diff --git a/.claude/skills/security-issue-import-from-md/SKILL.md
b/.claude/skills/security-issue-import-from-md/SKILL.md
index 4ca0d7c..f249cfd 100644
--- a/.claude/skills/security-issue-import-from-md/SKILL.md
+++ b/.claude/skills/security-issue-import-from-md/SKILL.md
@@ -360,7 +360,7 @@ the role → concrete-name mapping comes from
| `**Repository:**` + `**Branch:**` | `Affected versions` | Literal text
*"`<owner>/<repo>` @ `<branch>` — versions to be confirmed during triage."* The
release-train mapping happens at allocation. |
| (auto) | `Security mailing list thread` | `N/A — imported from markdown file
<basename>; no security@ thread.` |
| (auto) | `Public advisory URL` | `_No response_`. |
-| (auto) | `Reporter credited as` | `_No response_`. The credit decision
happens at triage; if the file is AI-generated, there is typically no human
finder to credit. If the markdown carries a `**Reporter:**` / `**Finder:**` /
`**Discovered by:**` metadata line naming a specific handle, **apply the
[bot/AI credit policy](../../../tools/vulnogram/bot-credits-policy.md)** before
lifting it into the field — when the policy fires (e.g. the markdown was
generated by an LLM scan and names the [...]
+| (auto) | `Reporter credited as` | `_No response_`. The credit decision
happens at triage; if the file is AI-generated, there is typically no human
finder to credit. If the markdown carries a `**Reporter:**` / `**Finder:**` /
`**Discovered by:**` metadata line naming a specific handle, **apply the
[bot/AI credit policy](../../../tools/vulnogram/bot-credits-policy.md)** before
lifting it into the field — when the policy fires (e.g. the markdown was
generated by an LLM scan and names the [...]
| `## Location` URL (when it points at a `<upstream>` PR) | `PR with the fix`
| The URL. Otherwise `_No response_` — the location commonly references a
vulnerable file, not a fix. |
| (auto) | `Remediation developer` | `_No response_`. |
| `**Category:**` | `CWE` | Literal value (free text); the actual CWE
assignment happens at triage / allocation. |
diff --git a/.claude/skills/security-issue-import/SKILL.md
b/.claude/skills/security-issue-import/SKILL.md
index a237c56..149a65e 100644
--- a/.claude/skills/security-issue-import/SKILL.md
+++ b/.claude/skills/security-issue-import/SKILL.md
@@ -995,7 +995,7 @@ Decide the candidate's class from the root message:
| Class | How to spot it | How to handle |
|---|---|---|
| **Report**: a reporter describes a vulnerability | The body has a
description, a PoC / reproduction steps, an impact claim. Sender is an external
address (not `@apache.org`, not on the security-team roster in
[`AGENTS.md`](../../../AGENTS.md)). | Proceed to Step 4. |
-| **ASF-security relay**: `[email protected]` forwarded a report from a
reporter via the Foundation channel | Sender is `[email protected]`. The body
almost always starts with the ASF forwarding preamble — *"Dear PMC, The
security vulnerability report has been received by the Apache Security Team and
is being passed to you for action …"* — and contains the original report
underneath (often after a `====GHSA-…` separator when the report came in via
GitHub Security Advisory). The pream [...]
+| **ASF-security relay**: `[email protected]` forwarded a report from a
reporter via the Foundation channel | Sender is `[email protected]`. The body
almost always starts with the ASF forwarding preamble — *"Dear PMC, The
security vulnerability report has been received by the Apache Security Team and
is being passed to you for action …"* — and contains the original report
underneath (often after a `====GHSA-…` separator when the report came in via
GitHub Security Advisory). The pream [...]
| **Report (disposition converged)**: a `Report` where the inbound thread has
a team-member substantive technical disposition AND the reporter has
acknowledged it | Same body shape as `Report`, but the thread has a team-member
reply with one of: option-1/option-2 framing, *"we agree, opening fix PR"*
disposition, a docs-clarification acknowledgement; AND the reporter has replied
confirming the disposition; AND no further reporter follow-up is needed.
Detected at Step 3 by reading the thr [...]
| **CVE-tool bookkeeping**: an automated or human status-change notification
on the ASF CVE tool | Sender is `[email protected]` (or one of the
security-team members acting on behalf of the CVE tool). Subject matches one
of: `"CVE-YYYY-NNNNN reserved for airflow"`, `"Comment added on
CVE-YYYY-NNNNN"`, `"CVE-YYYY-NNNNN is now READY"`, `"CVE-YYYY-NNNNN is now
PUBLIC"`, `"CVE-YYYY-NNNNN is now PUBLISHED"`, `"CVE-YYYY-NNNNN REJECTED"`, or
a verbatim `"<state-change>"` line in the body poin [...]
| **Automated scanner dump**: SAST/DAST tool output, CodeQL/Dependabot alert
paste, a string of "issues" with no human PoC | Body is machine-generated,
contains multiple unrelated findings, no explanation of Security Model
violation | Surface as a candidate with class `automated-scanner` and **do
not** propose auto-import. In Step 5 the skill proposes a Gmail draft from the
*"Automated scanning results"* canned response in
[`canned-responses.md`](../../../<project-config>/canned-response [...]
@@ -1067,7 +1067,7 @@ here.
| **Affected versions** | Extract `Airflow <version>` / `>= X, < Y` / `<Y`
phrases from the body. If the reporter gave only a single version they tested
on (e.g. `3.1.5`), record that verbatim; the triager can widen the range later.
Leave `_No response_` if no version is mentioned. |
| **Security mailing list thread** | **Keep the private thread handle, and —
if possible — also link the PonyMail archive entry.** The full URL-construction
recipe (search URL template, month-token format, user-pastes-back flow,
Gmail-threadId fallback) lives in
[`tools/gmail/ponymail-archive.md`](../../../tools/gmail/ponymail-archive.md#use-case--security-issue-import);
the adopting project's private-search URL template is declared in
[`<project-config>/project.md`](../../../<project-co [...]
| **Public advisory URL** | `_No response_`. Populated at Step 14 by
`security-issue-sync` once the advisory is archived. |
-| **Reporter credited as** | The reporter's full display name from the email
`From:` header (e.g. `Alice Example` from `"Alice Example"
<[email protected]>`). This is a **placeholder** — in direct-reporter mode, the
receipt-of-confirmation reply in Step 7 asks the reporter to confirm their
preferred credit form. **Apply the [bot/AI credit
policy](../../../tools/vulnogram/bot-credits-policy.md) before populating** —
if the `From:`-header name or address matches the bot detection rule (`*[ [...]
+| **Reporter credited as** | The reporter's full display name from the email
`From:` header (e.g. `Alice Example` from `"Alice Example"
<[email protected]>`). This is a **placeholder** — in direct-reporter mode, the
receipt-of-confirmation reply in Step 7 asks the reporter to confirm their
preferred credit form. **Apply the [bot/AI credit
policy](../../../tools/vulnogram/bot-credits-policy.md) before populating** —
if the `From:`-header name or address matches the bot detection rule (`*[ [...]
| **PR with the fix** | `_No response_`. |
| **Remediation developer** | `_No response_`. Auto-populated by the
`security-issue-sync` skill from the linked PR's author the first time *PR with
the fix* is set; manual edits are preserved on subsequent syncs. The
auto-populate step applies the same [bot/AI credit
policy](../../../tools/vulnogram/bot-credits-policy.md). |
| **CWE** | `_No response_`. The security team scores CWE independently; a
reporter-supplied CWE is informational only (per the *"Reporter-supplied CVSS
scores are informational only"* rule in [`AGENTS.md`](../../../AGENTS.md)). Do
**not** copy a CWE from the reporter's body into this field. |
diff --git a/.claude/skills/security-issue-sync/SKILL.md
b/.claude/skills/security-issue-sync/SKILL.md
index e560155..571bf98 100644
--- a/.claude/skills/security-issue-sync/SKILL.md
+++ b/.claude/skills/security-issue-sync/SKILL.md
@@ -640,14 +640,18 @@ Process for finding the real reporter and the original
thread:
**Apply the [bot/AI credit
policy](../../../tools/vulnogram/bot-credits-policy.md)
to the extracted credit string** before proposing the update. If the
credit handle matches the bot detection rule (`*[bot]` suffix,
- known-bot list, `*-bot`/`*-ai`/`*-agent`/`*-gpt` suffix patterns,
- `noreply`/`security-alerts@` service sender), do **not** propose
- landing the credit. Instead, surface in Step 2 *"skipped credit:
- `<handle>` (matches bot policy — `<which rule fired>`)"* **and
- propose a Gmail draft on the reporter's thread** per the policy's
- *clarification-reply* step, asking whether the AI/bot handle is
- the intended credit or whether there's a human behind it to credit
- instead. The user can override the skip per the policy doc.
+ known-bot list, `*-bot`/`*-ai`/`*-agent`/`*-gpt` suffix patterns),
+ propose landing the credit anyway — the CVE JSON generator will
+ emit it with `type: "tool"` per the policy's finder-side rule.
+ Surface in Step 2 *"credited as tool: `<handle>` (matches bot
+ policy — `<which rule fired>`)"* **and propose a Gmail draft on
+ the reporter's thread** per the policy's *clarification-reply*
+ step, asking whether a human behind the bot/AI handle should be
+ **additionally** credited as finder (the tool credit stands
+ regardless of the reply). The user can override the routing per
+ the policy doc. Service-sender addresses (noreply / relays) are
+ still suppressed from the field — they are routing artefacts, not
+ identities.
If the reporter has been *asked* the credit question but has not yet
responded, do not propose a change — leave the placeholder in place
diff --git a/tools/vulnogram/bot-credits-policy.md
b/tools/vulnogram/bot-credits-policy.md
index 5cb9d90..fb3ea80 100644
--- a/tools/vulnogram/bot-credits-policy.md
+++ b/tools/vulnogram/bot-credits-policy.md
@@ -6,6 +6,8 @@
- [Why](#why)
- [Detection — when does the rule fire?](#detection--when-does-the-rule-fire)
- [Default behaviour — what the skills do when the rule
fires](#default-behaviour--what-the-skills-do-when-the-rule-fires)
+ - [Finder side (*Reporter credited as*)](#finder-side-reporter-credited-as)
+ - [Remediation-developer side (*Remediation
developer*)](#remediation-developer-side-remediation-developer)
- [Where the rule applies](#where-the-rule-applies)
- [Where the rule does NOT apply](#where-the-rule-does-not-apply)
- [Worked examples](#worked-examples)
@@ -17,34 +19,52 @@
# CVE-credit policy for bot / AI accounts
-The CVE record's `credits[]` array records *people* who discovered a
-vulnerability (`type: "finder"`) or shipped the fix
-(`type: "remediation developer"`). When an obvious bot or AI account
-appears as the candidate for either role, the default is to **not**
-credit them. The skills that populate the *Reporter credited as* and
-*Remediation developer* tracker body fields enforce this rule at the
-point of extraction, and ask the user before adding the credit when
-the rule matches.
-
-This file is the single source of truth for *who counts as a bot* and
-*how the skills behave when one is detected*. Skills reference it
-instead of duplicating the heuristic.
+The CVE 5.x `credits[]` schema distinguishes between people who
+discovered a vulnerability (`type: "finder"`), automation that
+discovered it (`type: "tool"`), and people who shipped the fix
+(`type: "remediation developer"`). When an obvious bot or AI
+account appears as a candidate credit, the framework routes it
+asymmetrically:
+
+* **On the finder side** (*Reporter credited as* body field), the
+ bot is credited with `type: "tool"`. Scanners, AI agents, and
+ automation that surface a real vulnerability deserve the credit
+ — just under the schema's tool category, not as a human finder.
+* **On the remediation-developer side** (*Remediation developer*
+ body field), the bot is **not** credited. A dependency-bump
+ PR from Dependabot is the automation doing what humans
+ configured it to do, not a credit-worthy remediation effort.
+
+This file is the single source of truth for *who counts as a bot*
+and *how the skills + the CVE JSON generator behave when one is
+detected*. Both reference it instead of duplicating the heuristic.
## Why
* Bot accounts (Dependabot, Renovate, GHSA Probot, GitHub Actions,
Snyk, Mend, …) act on behalf of an organisation or a piece of
- automation — they are not the *person* who found the bug or fixed
- it. Naming them as `finder` or `remediation developer` in a public
- CVE record misrepresents authorship and pollutes the CNA-feed
- with handles that are not actionable as contributor credit.
-* AI / LLM agents that opened a PR or filed a report are tools the
- human used. The human who drove the agent — if there is one
- identifiable in the thread — is the candidate for credit. The
- agent itself never is.
+ automation. Naming them as `finder` in a public CVE record is
+ inaccurate — they are not human researchers. Naming them as
+ `tool` is accurate and is what CVE 5.x's
+ [credit-type
enum](https://cveproject.github.io/cve-schema/schema/CVE_Record_Format.json)
+ was designed for: the same axis as `finder`, just for automation.
+* AI / LLM agents that opened a PR or filed a report fit the same
+ shape — they are tools the human used. If the human behind the
+ agent is identifiable in the thread, they get the human
+ `finder` credit *in addition to* the tool credit; the agent
+ itself is the tool row.
* Forwarding services (`[email protected]`,
`security-alerts@<scanner>.com`, …) sit between the actual
- reporter and us; their address is a relay, not an identity.
+ reporter and us; their address is a relay, not an identity. They
+ are not credited at all — the actual reporter (or the scanner
+ whose alerts the relay forwards) is what the skills extract.
+* On the remediation-developer side the asymmetry is intentional:
+ a CVE record's remediation credit speaks to *who fixed it*, and
+ a Dependabot dep-bump (or any other "automation did the
+ mechanical change") does not warrant credit. There is no
+ remediation-side equivalent to `type: "tool"` — the cleanest
+ expression of "no human deserves credit here" is to omit the
+ row entirely.
## Detection — when does the rule fire?
@@ -85,84 +105,101 @@ public CVE record).
## Default behaviour — what the skills do when the rule fires
-Each skill that extracts a credit candidate applies these rules at
-extraction time:
+The action depends on which credit field is involved.
-1. **Skip silently in the data flow.** Do not write the bot's name
- into the *Reporter credited as* or *Remediation developer* body
- field. Leave the field at its current value (typically
- `_No response_` for a fresh tracker, or whatever the prior
- resolved value was for an existing tracker).
-2. **Surface the skip in the user-facing proposal.** Include a
- one-line entry in the proposal's "skipped" section of the shape:
+### Finder side (*Reporter credited as*)
- ```text
- skipped credit: <handle> (matches bot policy — ends with [bot])
- ^^^^^ which rule matched
- skipped credit: <handle> (matches bot policy — in known-bot list)
- skipped credit: <handle> (matches bot policy — *-bot suffix pattern)
- skipped credit: <email> (matches bot policy — noreply service sender)
- ```
+The skills that extract a reporter-credit candidate (see
+[Where the rule applies](#where-the-rule-applies) below) treat a
+detected bot as a **tool credit**:
- The reason — *which* rule fired — is mandatory; it lets the user
- judge whether the skip is correct.
-3. **Honour an explicit override.** The user may override the skip
- for a specific tracker with any of these phrasings (or obvious
- variants):
+1. **Include the bot in the *Reporter credited as* field**, on
+ its own line, exactly as detected. The
+ [CVE JSON generator](generate-cve-json/) reads the field on its
+ next regeneration, runs the same detection rule on every line,
+ and emits the bot row with `type: "tool"` (instead of the
+ default `type: "finder"`). The generator is the single point
+ where `finder`-vs-`tool` is decided — skills do not need to
+ annotate the field.
+2. **Surface the routing in the user-facing proposal.** Include a
+ one-line entry of the shape:
- * *"include `<handle>` anyway"*
- * *"credit `<handle>` as finder"*
- * *"credit `<handle>` as remediation developer"*
- * *"yes, add `<handle>`"*
+ ```text
+ credited as tool: <handle> (matches bot policy — ends with [bot])
+ ^^^^^ which rule matched
+ credited as tool: <handle> (matches bot policy — in known-bot list)
+ credited as tool: <handle> (matches bot policy — *-bot suffix pattern)
+ credited as tool: <handle> (matches bot policy — *scanner* pattern)
+ ```
- When the user confirms the override, set the appropriate body
- field exactly as the user dictated. Do not auto-extend the
- override to other trackers — overrides are per-tracker.
+ The reason — *which* rule fired — is mandatory; it lets the
+ user judge whether the routing is correct.
+3. **Honour an explicit override.** The user may override the
+ tool routing for a specific tracker with any of these
+ phrasings (or obvious variants):
+
+ * *"credit `<handle>` as finder"* — the credit lands in the
+ field; the user is explicitly telling the generator the
+ handle is a human researcher despite matching the
+ heuristic. Today the generator still re-classifies on
+ pattern match, so an override of this shape requires
+ editing the row to a form that does not match (e.g.
+ `Alice (Dependabot Security Research)` → drop the
+ `Dependabot` word, or move the affiliation outside the
+ credit string).
+ * *"omit `<handle>` from credits"* — drop the row entirely
+ (the tool is not credit-worthy in this specific case).
+ * *"yes, credit `<handle>` as tool"* — explicit confirmation
+ of the default; no action needed beyond proceeding.
+
+ Overrides are per-tracker; do not auto-extend.
4. **Draft a clarification reply to the reporter when an email
thread exists *and* the tracker is in direct-reporter mode.**
- When the candidate would have been credited as the *finder*
- (i.e. the reporter themselves is the bot-looking name), the
- tracker has an inbound `<security-list>` mail thread to reply
- on, **and** the tracker's routing mode (per
+ When the bot/AI handle is the only candidate the skill found
+ for the *finder* role, the tracker has an inbound
+ `<security-list>` mail thread, **and** the tracker's routing
+ mode (per
[`docs/security/forwarder-routing-policy.md`](../../docs/security/forwarder-routing-policy.md))
is *direct-reporter*, propose a **Gmail draft** (not a sent
- message) on the same thread asking whether the bot/AI handle
- is the intended credit or whether there's a human behind it
- who should be credited instead. The draft should be:
+ message) on the same thread. The draft asks whether a human
+ behind the bot/AI should be **additionally** credited as
+ `finder` — the bot stays in the field as a tool credit
+ regardless; the question is whether to *also* add a human
+ finder row. The draft should be:
* **Polite and short** — one or two short paragraphs; no
accusations, no jargon.
- * **Specific** — name the handle that was detected and which
- rule fired (so the reporter sees the same reasoning the
- security team did).
- * **Actionable** — offer two clear paths: *"credit `<handle>`
- as-is"* or *"credit `<human-name>` instead — please reply
- with the preferred attribution"*.
+ * **Specific** — name the handle that was credited as tool
+ and the rule that fired (so the reporter sees the same
+ reasoning the security team did).
+ * **Actionable** — offer one clear path: *"if a human was
+ behind `<handle>` who should also be credited as finder,
+ please reply with their name; otherwise the tool credit
+ stands as-is"*.
* **Sent only after explicit user confirmation** per the
[framework's never-send-without-asking rule](../../AGENTS.md).
**In via-forwarder mode the standalone clarification draft
- is suppressed.** It is a *credit-acceptance confirmation*
- message (asking the reporter to confirm the AI/bot handle is
- the intended credit, or to accept a different one) — and
- credit-acceptance confirmations are on the
+ is suppressed.** Even with the new tool-credit default it
+ remains a *credit-acceptance confirmation* message (asking
+ the reporter to confirm or extend the credit attribution),
+ and credit-acceptance confirmations are on the
[forwarder-routing-policy negative
list](../../docs/security/forwarder-routing-policy.md#negative-space--do-not-relay).
- The forwarder cannot meaningfully accept a credit on behalf
- of the original reporter, so the message becomes a chase-up
- loop. The bot-credit detection still runs and still keeps
- the bot/AI handle out of the credit field; what is suppressed
- is the dedicated message to confirm the alternative.
-
- The credit *question* itself (initial ask, *"if the reporter
- has a preferred credit form, please pass it back"*) is **not**
- suppressed — it is folded as a single line into whatever
- milestone draft the via-forwarder lifecycle next produces (the
- Step 7 receipt-of-confirmation, the *Report accepted as
- valid* milestone, the *CVE allocated* notification). The
- credit field stays at `_No response_` (or whatever the
- original report's `Credit:` line yielded after bot filtering)
- until a meaningful answer comes back.
+ The forwarder cannot meaningfully accept or extend a credit
+ on behalf of the original reporter. The bot-credit detection
+ still runs, the tool row still lands in the field, and the
+ generator still emits `type: "tool"` for it; what is
+ suppressed is the dedicated message asking for a human
+ addition.
+
+ The credit *question* itself (initial ask, *"if a human was
+ behind the tool, please pass back their preferred
+ attribution"*) is **not** suppressed in via-forwarder mode —
+ it is folded as a single line into whatever milestone draft
+ the lifecycle next produces (the Step 7 receipt-of-
+ confirmation, the *Report accepted as valid* milestone, the
+ *CVE allocated* notification).
When the tracker has no inbound mail thread at all (e.g. a
`security-issue-import-from-pr` tracker — the
@@ -175,30 +212,71 @@ extraction time:
inline and propose adding it to the canned-responses file as a
follow-up.
+### Remediation-developer side (*Remediation developer*)
+
+The skills that extract a remediation-developer candidate (see
+[Where the rule applies](#where-the-rule-applies) below) **skip**
+the bot — there is no remediation-side equivalent of `type:
+"tool"`, and a dependency-bump from automation does not warrant
+a credit row. Behaviour:
+
+1. **Skip silently in the data flow.** Do not write the bot's
+ name into the *Remediation developer* body field. Leave the
+ field at its current value (typically `_No response_` for a
+ fresh tracker, or whatever the prior resolved value was for
+ an existing tracker).
+2. **Surface the skip in the user-facing proposal.** Include a
+ one-line entry of the shape:
+
+ ```text
+ skipped credit: <handle> (matches bot policy — ends with [bot])
+ skipped credit: <handle> (matches bot policy — in known-bot list)
+ skipped credit: <handle> (matches bot policy — *-bot suffix pattern)
+ ```
+3. **Honour an explicit override** with the same per-tracker
+ phrasings as the finder side (e.g. *"credit `<handle>` as
+ remediation developer"*).
+4. **No clarification draft.** The remediation-developer field is
+ reconciled by the skills from PR-author signals, not from
+ reporter input — there is no thread to ask. If a human
+ committer was the *real* remediation developer (the bot only
+ pushed mechanical formatting), the user adds them with an
+ explicit override.
+
## Where the rule applies
This policy fires at every site where the suite *auto-extracts* a
credit candidate without explicit user instruction:
-| Skill | Extraction site |
-|---|---|
-|
[`security-issue-import`](../../.claude/skills/security-issue-import/SKILL.md)
| Reporter name from email `From:` header; ASF-relay `Credit:` line |
-|
[`security-issue-import-from-pr`](../../.claude/skills/security-issue-import-from-pr/SKILL.md)
| PR author → *Remediation developer* |
-|
[`security-issue-import-from-md`](../../.claude/skills/security-issue-import-from-md/SKILL.md)
| Reporter / finder name from markdown metadata |
-| [`security-issue-sync`](../../.claude/skills/security-issue-sync/SKILL.md) |
Reporter credit mined from email replies; PR author auto-append to *Remediation
developer* |
-|
[`security-issue-deduplicate`](../../.claude/skills/security-issue-deduplicate/SKILL.md)
| Credit consolidation from two trackers |
+| Skill | Extraction site | Field | Action |
+|---|---|---|---|
+|
[`security-issue-import`](../../.claude/skills/security-issue-import/SKILL.md)
| Reporter name from email `From:` header; ASF-relay `Credit:` line | *Reporter
credited as* | Include → tool |
+|
[`security-issue-import-from-pr`](../../.claude/skills/security-issue-import-from-pr/SKILL.md)
| PR author | *Remediation developer* | Skip |
+|
[`security-issue-import-from-md`](../../.claude/skills/security-issue-import-from-md/SKILL.md)
| Reporter / finder name from markdown metadata | *Reporter credited as* |
Include → tool |
+| [`security-issue-sync`](../../.claude/skills/security-issue-sync/SKILL.md) |
Reporter credit mined from email replies | *Reporter credited as* | Include →
tool |
+| [`security-issue-sync`](../../.claude/skills/security-issue-sync/SKILL.md) |
PR author auto-append | *Remediation developer* | Skip |
+|
[`security-issue-deduplicate`](../../.claude/skills/security-issue-deduplicate/SKILL.md)
| Credit consolidation from two trackers | both | Per-row by field type |
The rule does **not** fire when the user *explicitly* types a name
into a credit field, or when a tracker already carries a credit that
a human security-team member set — those are explicit decisions and
-the skill respects them.
+the skill respects them. (On the finder side, the generator will
+still re-classify the row as `tool` if the name matches the policy;
+to force a human `finder` row, the user must edit the row to a form
+that does not match the heuristic — see the override note in
+[Default behaviour → Finder side](#finder-side-reporter-credited-as)
+above.)
## Where the rule does NOT apply
-* [`generate-cve-json`](generate-cve-json/SKILL.md) stays neutral.
- Whatever is in the tracker's credit fields is what lands in the
- CVE JSON. The filter is upstream, at the skills, so that an
- intentional human override survives JSON regeneration.
+* [`generate-cve-json`](generate-cve-json/) **applies the bot
+ detection on the finder side** to decide between `type: "finder"`
+ and `type: "tool"`. It does **not** filter rows out (every
+ non-empty line in *Reporter credited as* becomes a credit entry)
+ and it does **not** apply the detection on the remediation-
+ developer side — those decisions live in the skills upstream so
+ an intentional human override on the remediation side survives
+ JSON regeneration.
* The *PR with the fix* body field on a tracker still records the
PR even when its author is a bot — the field captures the
artifact, not the author. Only *Remediation developer* is
@@ -207,41 +285,70 @@ the skill respects them.
## Worked examples
**`security-issue-import-from-pr` import of a Dependabot PR.** The
-PR's author is `dependabot[bot]`. The skill skips the
-*Remediation developer* assignment and surfaces:
-`skipped credit: dependabot[bot] (matches bot policy — ends with
-[bot])`. The user can override with *"credit dependabot[bot] as
-remediation developer"* if a real human at Dependabot HQ is owed the
-credit (unusual but allowed).
+PR's author is `dependabot[bot]`. The skill **skips** the
+*Remediation developer* assignment (remediation side) and
+surfaces: `skipped credit: dependabot[bot] (matches bot policy —
+ends with [bot])`. The user can override with *"credit
+dependabot[bot] as remediation developer"* if a real human at
+Dependabot HQ is owed the credit (unusual but allowed). No
+finder-side action — the PR-import path does not extract a
+reporter credit.
**`security-issue-sync` mining a reporter email reply.** The
reporter wrote *"please credit me as claude-bot, I used Claude
-Code"*. The skill skips the credit, surfaces `skipped credit:
-claude-bot (matches bot policy — *-bot suffix pattern)`, **and
-proposes a Gmail draft** on the original report thread asking
-*"the credit you suggested (`claude-bot`) reads like an AI/agent
-handle — would you prefer we credit you under your name (e.g. the
-one on the original report) instead, or is `claude-bot` the
-attribution you want?"* The user reviews the draft and approves
-the send. If the reporter replies with a human name, sync picks
-it up on the next pass.
+Code"*. The skill **includes** `claude-bot` in *Reporter credited
+as*, surfaces `credited as tool: claude-bot (matches bot policy —
+*-bot suffix pattern)`, **and proposes a Gmail draft** on the
+original report thread asking *"we've credited `claude-bot` as a
+tool (CVE 5.x `type: tool`); if a human was behind it who should
+also be credited as finder, please reply with their name —
+otherwise the tool credit stands as-is"*. The user reviews the
+draft and approves the send. If the reporter replies with a human
+name, sync picks it up on the next pass and the generator emits
+a second credit row of `type: "finder"` alongside the tool row.
**`security-issue-import` from a relay address.** The `From:`
-header is `[email protected]`. The skill skips
-*Reporter credited as*, surfaces `skipped credit:
[email protected] (matches bot policy — noreply
-service sender)`, and prompts the user for the actual reporter
-identity (typically findable inside the email body).
+header is `[email protected]`. The relay
+address is a routing artefact, not a credit candidate — the skill
+extracts the actual reporter from the email body and credits
+that. The relay sender never lands in *Reporter credited as* at
+all (the noreply-service rule prevents the From-header heuristic
+from picking it up). If the email body contains
+*"automated scan by SecurityScanner-7"*, that string lands in the
+field and the generator emits it with `type: "tool"`.
**`security-issue-import` of an ASF-relay report with an
automation credit line.** The forwarded body ends with *"This
vulnerability was discovered and reported by Automated Security
Scanner v3 (run by ACME Sec Team)"*. The skill matches both the
`automated` known-name and the `*scanner*` contains-pattern,
-skips *Reporter credited as*, surfaces `skipped credit:
-"Automated Security Scanner v3" (matches bot policy — known
-automation name + *scanner* pattern)`, and routes the
-credit-preference question to `@raboof` / Arnout via the
-ASF-relay credit-preference flow. The user can override with
-*"credit ACME Sec Team as finder"* if the human team behind the
-scanner is owed the credit.
+**includes** the string in *Reporter credited as*, surfaces
+`credited as tool: "Automated Security Scanner v3" (matches bot
+policy — known automation name + *scanner* pattern)`, and folds
+the *"is there a human at ACME Sec Team who should also be
+credited as finder?"* question into the next milestone draft
+routed to `@raboof` / Arnout via the ASF-relay credit-preference
+flow. The user can override with *"add ACME Sec Team as finder"*
+to land a second human credit row alongside the tool row.
+
+**`generate-cve-json` regeneration with a mixed-credit field.**
+The *Reporter credited as* field on tracker #NNN currently
+reads:
+
+```text
+Alice Smith, ACME Security Research
+Dependabot
+```
+
+The generator emits two `credits[]` entries:
+
+```json
+[
+ {"lang": "en", "type": "finder", "value": "Alice Smith, ACME Security
Research"},
+ {"lang": "en", "type": "tool", "value": "Dependabot"}
+]
+```
+
+No skill action is needed at regeneration time — the field text
+is the source of truth; the type assignment is computed per row
+from `is_bot_credit()` in the generator.
diff --git
a/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
b/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
index 21b45f5..3bf04c2 100644
--- a/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
+++ b/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
@@ -37,11 +37,15 @@ record. In particular, the script produces:
* ``problemTypes[].descriptions[]`` with ``cweId``, ``description`` and
``type`` = *"CWE"*;
* ``credits[]`` entries from the *Reporter credited as* field
- (``type: "finder"``) and the *Remediation developer* field
- (``type: "remediation developer"``) — both newline-separated, with
- the ``Full Name, Affiliation`` pattern preserved as one credit; the
- ``--remediation-developer`` CLI flag still works as an additional /
- override mechanism on top of the body field;
+ (``type: "finder"`` by default; ``type: "tool"`` when the credit
+ matches the bot/AI policy in
+ ``tools/vulnogram/bot-credits-policy.md`` — Dependabot, Renovate,
+ Snyk, scanners, ``*-bot`` / ``*[bot]`` handles, etc.) and the
+ *Remediation developer* field (``type: "remediation developer"``)
+ — both newline-separated, with the ``Full Name, Affiliation``
+ pattern preserved as one credit; the ``--remediation-developer``
+ CLI flag still works as an additional / override mechanism on top
+ of the body field;
* ``references[]`` with automatic ``tags`` (``patch`` for
``github.com/.../pull/...`` URLs, ``vendor-advisory`` for
``lists.apache.org``/``security.apache.org``). URLs from the
@@ -248,9 +252,103 @@ TRACKER_FILTER_TOKEN: str = ""
# CVE 5.x convention values that are not project-specific.
DEFAULT_CREDIT_TYPE = "finder"
+TOOL_CREDIT_TYPE = "tool"
DEFAULT_LANG = "en"
DEFAULT_DISCOVERY = "UNKNOWN"
+# Bot / AI / automation detection for the *Reporter credited as*
+# field. When a credit row matches any of these rules, the CVE 5.x
+# ``credits[]`` entry is emitted with ``type: "tool"`` instead of
+# ``type: "finder"``. The matching rules mirror
+# ``tools/vulnogram/bot-credits-policy.md`` (single source of truth);
+# update both together. Detection is intentionally broad — false
+# positives are cheap (the user can edit the JSON afterwards or set
+# the credit name to a clearer human-style string), false negatives
+# put a bot handle into the ``finder`` credit class in a public CVE
+# record, which is what this policy exists to prevent.
+#
+# Known bot / automation names matched as a case-insensitive
+# whole-word against the credit string (e.g. ``"Dependabot"``,
+# ``"discovered by Automated Scanner v3"``).
+BOT_CREDIT_KNOWN_NAMES: tuple[str, ...] = (
+ "dependabot",
+ "renovate",
+ "snyk-bot",
+ "snyk",
+ "copilot",
+ "ghsa-probot",
+ "github-actions",
+ "mend-bot",
+ "mend",
+ "whitesource",
+ "sonatype-lift",
+ "lift-bot",
+ "codecov",
+ "mergify",
+ "mergifyio",
+ "allcontributors",
+ "fossabot",
+ "imgbotapp",
+ "pre-commit-ci",
+ "claude",
+ "chatgpt",
+ "gpt-bot",
+ "gpt",
+ "anthropic",
+ "openai",
+ "automated",
+ "automation",
+ "scanner",
+ "auto-scanner",
+ "vulnerability-scanner",
+ "security-scanner",
+ "sast",
+ "dast",
+)
+
+# Handle-shaped patterns matched as case-insensitive regexes against
+# the credit string. ``\b`` anchors keep them from firing on
+# unrelated human names (``Joe Bot`` does not match ``*-bot`` because
+# the space breaks the word boundary, but ``joe-bot`` does).
+BOT_CREDIT_PATTERNS: tuple[re.Pattern[str], ...] = (
+ re.compile(r"\b[\w]*-bot\b", re.IGNORECASE),
+ re.compile(r"\b[\w]+bot\b", re.IGNORECASE),
+ re.compile(r"\b[\w]*-ai\b", re.IGNORECASE),
+ re.compile(r"\b[\w]*-agent\b", re.IGNORECASE),
+ re.compile(r"\b[\w]*-gpt\b", re.IGNORECASE),
+ re.compile(r"\b[\w]*scanner[\w]*\b", re.IGNORECASE),
+ re.compile(r"\b[\w]*automat[\w]*\b", re.IGNORECASE),
+)
+
+
+def is_bot_credit(name: str) -> bool:
+ """Return True if *name* matches the bot/AI credit policy.
+
+ Matches any of three rules:
+
+ 1. Literal GitHub ``[bot]`` suffix — handle ends in ``[bot]``
+ (e.g. ``dependabot[bot]``).
+ 2. Known-bot / automation-name list — case-insensitive whole-word
+ occurrence of any of ``BOT_CREDIT_KNOWN_NAMES`` in the credit
+ string.
+ 3. Suffix / contains-pattern handles — any of
+ ``BOT_CREDIT_PATTERNS`` matches.
+
+ See ``tools/vulnogram/bot-credits-policy.md`` for the canonical
+ rule set and rationale.
+ """
+ cleaned = name.strip()
+ if not cleaned:
+ return False
+ if cleaned.endswith("[bot]"):
+ return True
+ lowered = cleaned.lower()
+ for known in BOT_CREDIT_KNOWN_NAMES:
+ if re.search(rf"\b{re.escape(known)}\b", lowered):
+ return True
+ return any(pattern.search(cleaned) for pattern in BOT_CREDIT_PATTERNS)
+
+
# Populate at import time. If the config is not present, defer the
# error to first actual use so test harnesses can call
# `_set_config_path()` before exercising the module.
@@ -864,7 +962,8 @@ def build_credits(
) -> list[dict]:
credits: list[dict] = []
for name in parse_credits_from_field(credited_as_value):
- credits.append({"lang": DEFAULT_LANG, "type": DEFAULT_CREDIT_TYPE,
"value": name})
+ credit_type = TOOL_CREDIT_TYPE if is_bot_credit(name) else
DEFAULT_CREDIT_TYPE
+ credits.append({"lang": DEFAULT_LANG, "type": credit_type, "value":
name})
for name in remediation_developers:
cleaned = name.strip()
if cleaned:
diff --git a/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
b/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
index 148054d..2615577 100644
--- a/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
+++ b/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
@@ -58,7 +58,7 @@ from generate_cve_json import (
resolve_title,
wrap_cve_record,
)
-from generate_cve_json.cve_json import normalise_severity, to_html
+from generate_cve_json.cve_json import is_bot_credit, normalise_severity,
to_html
DEFAULT_AFFECTED_ARGS: dict[str, Any] = {
"vendor": "Apache Software Foundation",
@@ -140,6 +140,139 @@ class TestParseCreditsFromField:
assert parse_credits_from_field("") == []
+# ---------------------------------------------------------------------------
+# Bot / AI credit detection (drives `type: "tool"` in build_credits)
+# ---------------------------------------------------------------------------
+
+
+class TestIsBotCredit:
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "dependabot[bot]",
+ "github-actions[bot]",
+ "renovate[bot]",
+ "copilot[bot]",
+ "ghsa-probot[bot]",
+ ],
+ )
+ def test_github_bot_suffix_matches(self, name: str):
+ assert is_bot_credit(name) is True
+
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "Dependabot",
+ "DEPENDABOT",
+ "Snyk",
+ "Renovate",
+ "github-actions",
+ "Claude",
+ "ChatGPT",
+ "Mend",
+ "Whitesource",
+ ],
+ )
+ def test_known_name_list_matches_case_insensitively(self, name: str):
+ assert is_bot_credit(name) is True
+
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "discovered by Automated Scanner v3",
+ "reported via security-scanner",
+ "found by Renovate during dependency sweep",
+ ],
+ )
+ def test_known_name_matches_inside_free_form_string(self, name: str):
+ assert is_bot_credit(name) is True
+
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "claude-bot",
+ "release-bot",
+ "securitybot",
+ "scan-ai",
+ "triage-agent",
+ "secaudit-gpt",
+ "securityscanner-7",
+ "automated-triage",
+ "automaton",
+ ],
+ )
+ def test_pattern_handles_match(self, name: str):
+ assert is_bot_credit(name) is True
+
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "Alice Smith",
+ "Bob Jones (Acme Corp)",
+ "Jane Doe, Acme Security",
+ "Joe Bot", # space breaks word boundary on *bot/-bot patterns
+ "Botev Martinov", # word boundary stops the bot suffix at "Botev"
+ "Renovation Engineering Inc", # 'renovate' substring, not whole
word
+ "AI Research Lab", # uppercase free-standing; no *-ai handle shape
+ ],
+ )
+ def test_human_names_do_not_match(self, name: str):
+ assert is_bot_credit(name) is False
+
+ def test_empty_string_does_not_match(self):
+ assert is_bot_credit("") is False
+
+ def test_whitespace_only_does_not_match(self):
+ assert is_bot_credit(" ") is False
+
+
+class TestBuildCreditsBotTypeAssignment:
+ def test_plain_human_credit_gets_finder_type(self):
+ credits = build_credits("Alice Smith", remediation_developers=[])
+ assert credits == [{"lang": "en", "type": "finder", "value": "Alice
Smith"}]
+
+ def test_github_bot_suffix_credit_gets_tool_type(self):
+ credits = build_credits("dependabot[bot]", remediation_developers=[])
+ assert credits == [{"lang": "en", "type": "tool", "value":
"dependabot[bot]"}]
+
+ def test_known_bot_name_credit_gets_tool_type(self):
+ credits = build_credits("Dependabot", remediation_developers=[])
+ assert credits == [{"lang": "en", "type": "tool", "value":
"Dependabot"}]
+
+ def test_pattern_handle_credit_gets_tool_type(self):
+ credits = build_credits("claude-bot", remediation_developers=[])
+ assert credits == [{"lang": "en", "type": "tool", "value":
"claude-bot"}]
+
+ def test_mixed_credits_get_per_row_types(self):
+ credits = build_credits(
+ "Alice Smith\nDependabot\nBob Jones (Acme Corp)",
+ remediation_developers=[],
+ )
+ assert credits == [
+ {"lang": "en", "type": "finder", "value": "Alice Smith"},
+ {"lang": "en", "type": "tool", "value": "Dependabot"},
+ {"lang": "en", "type": "finder", "value": "Bob Jones (Acme Corp)"},
+ ]
+
+ def test_remediation_developer_type_unaffected_by_bot_policy(self):
+ """Remediation-developer side is intentionally NOT routed through
tool."""
+ credits = build_credits(
+ "Alice Smith",
+ remediation_developers=["dependabot[bot]"],
+ )
+ # Bot lands as remediation developer with its original type — the
+ # finder-side tool routing does not extend here. Skipping bots on
+ # the remediation side is a separate (upstream-skill) concern.
+ assert credits == [
+ {"lang": "en", "type": "finder", "value": "Alice Smith"},
+ {
+ "lang": "en",
+ "type": "remediation developer",
+ "value": "dependabot[bot]",
+ },
+ ]
+
+
# ---------------------------------------------------------------------------
# URL list parsing
# ---------------------------------------------------------------------------