scamscript 0.7.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {scamscript-0.7.0 → scamscript-0.8.0}/CHANGELOG.md +13 -0
  2. {scamscript-0.7.0 → scamscript-0.8.0}/PKG-INFO +23 -4
  3. {scamscript-0.7.0 → scamscript-0.8.0}/README.md +22 -3
  4. {scamscript-0.7.0 → scamscript-0.8.0}/docs/real-corpus.md +7 -0
  5. {scamscript-0.7.0 → scamscript-0.8.0}/pyproject.toml +1 -1
  6. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/__init__.py +1 -1
  7. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/analysis.py +7 -0
  8. scamscript-0.8.0/src/scamscript/brief.py +178 -0
  9. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/cli.py +44 -1
  10. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/client.py +1 -1
  11. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/models.py +17 -0
  12. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/prompts.py +2 -0
  13. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/render.py +1 -0
  14. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/report.py +10 -2
  15. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/taxonomy.py +16 -0
  16. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_brief.py +19 -0
  17. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_cli.py +52 -8
  18. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_report.py +10 -1
  19. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_taxonomy.py +22 -0
  20. scamscript-0.7.0/src/scamscript/brief.py +0 -95
  21. {scamscript-0.7.0 → scamscript-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  22. {scamscript-0.7.0 → scamscript-0.8.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  23. {scamscript-0.7.0 → scamscript-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  24. {scamscript-0.7.0 → scamscript-0.8.0}/.github/SECURITY.md +0 -0
  25. {scamscript-0.7.0 → scamscript-0.8.0}/.github/pull_request_template.md +0 -0
  26. {scamscript-0.7.0 → scamscript-0.8.0}/.github/workflows/ci.yml +0 -0
  27. {scamscript-0.7.0 → scamscript-0.8.0}/.github/workflows/release.yml +0 -0
  28. {scamscript-0.7.0 → scamscript-0.8.0}/.gitignore +0 -0
  29. {scamscript-0.7.0 → scamscript-0.8.0}/CITATION.cff +0 -0
  30. {scamscript-0.7.0 → scamscript-0.8.0}/CODE_OF_CONDUCT.md +0 -0
  31. {scamscript-0.7.0 → scamscript-0.8.0}/CONTRIBUTING.md +0 -0
  32. {scamscript-0.7.0 → scamscript-0.8.0}/LICENSE +0 -0
  33. {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-commonwealth-bank-card-vishing.txt +0 -0
  34. {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-credit-union-card-vishing.txt +0 -0
  35. {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-apple-support-robocall.txt +0 -0
  36. {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-interest-rate-robocall.txt +0 -0
  37. {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-irs-settlement-robocall.txt +0 -0
  38. {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-ssa-suspension-robocall.txt +0 -0
  39. {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-student-loan-robocall.txt +0 -0
  40. {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-utility-rebate-robocall.txt +0 -0
  41. {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-medicare-benefits-vishing.txt +0 -0
  42. {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-usps-invalid-zip-smishing.txt +0 -0
  43. {scamscript-0.7.0 → scamscript-0.8.0}/docs/banner.svg +0 -0
  44. {scamscript-0.7.0 → scamscript-0.8.0}/docs/calibration.md +0 -0
  45. {scamscript-0.7.0 → scamscript-0.8.0}/docs/demo.gif +0 -0
  46. {scamscript-0.7.0 → scamscript-0.8.0}/docs/sample-report.md +0 -0
  47. {scamscript-0.7.0 → scamscript-0.8.0}/docs/social-preview.png +0 -0
  48. {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/fake_output_json.txt +0 -0
  49. {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/fence_escape.txt +0 -0
  50. {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/fixed_attack_type.txt +0 -0
  51. {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/non_json_demand.txt +0 -0
  52. {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/print_system_prompt.txt +0 -0
  53. {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/role_switch.txt +0 -0
  54. {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/score_override.txt +0 -0
  55. {scamscript-0.7.0 → scamscript-0.8.0}/examples/crude_robocall.txt +0 -0
  56. {scamscript-0.7.0 → scamscript-0.8.0}/examples/gov_impersonation_vishing.txt +0 -0
  57. {scamscript-0.7.0 → scamscript-0.8.0}/examples/prompt_injection_script.txt +0 -0
  58. {scamscript-0.7.0 → scamscript-0.8.0}/examples/romance_scam_sms.txt +0 -0
  59. {scamscript-0.7.0 → scamscript-0.8.0}/examples/sophisticated_wire_fraud.txt +0 -0
  60. {scamscript-0.7.0 → scamscript-0.8.0}/examples/tech_support_scam.txt +0 -0
  61. {scamscript-0.7.0 → scamscript-0.8.0}/scripts/calibrate.py +0 -0
  62. {scamscript-0.7.0 → scamscript-0.8.0}/scripts/injection_eval.py +0 -0
  63. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/__main__.py +0 -0
  64. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/config.py +0 -0
  65. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/dedupe.py +0 -0
  66. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/ingest.py +0 -0
  67. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/language.py +0 -0
  68. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/redact.py +0 -0
  69. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/store.py +0 -0
  70. {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/verify.py +0 -0
  71. {scamscript-0.7.0 → scamscript-0.8.0}/tests/fixtures/reprints/copy-header.txt +0 -0
  72. {scamscript-0.7.0 → scamscript-0.8.0}/tests/fixtures/reprints/copy-punctuation.txt +0 -0
  73. {scamscript-0.7.0 → scamscript-0.8.0}/tests/fixtures/reprints/near-twin.txt +0 -0
  74. {scamscript-0.7.0 → scamscript-0.8.0}/tests/fixtures/reprints/original.txt +0 -0
  75. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_analysis.py +0 -0
  76. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_client.py +0 -0
  77. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_dedupe.py +0 -0
  78. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_ingest.py +0 -0
  79. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_language.py +0 -0
  80. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_models.py +0 -0
  81. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_prompts.py +0 -0
  82. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_redact.py +0 -0
  83. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_render.py +0 -0
  84. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_store.py +0 -0
  85. {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_verify.py +0 -0
@@ -5,6 +5,19 @@ schema are the things that count as public API.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.8.0 - 2026-11-02
9
+
10
+ ### Added
11
+
12
+ - `channel` field (`vishing`, `smishing`, `email`, `other`) separate from the attack theme, with
13
+ `--channel` filtering in reports and briefings.
14
+ - `brief --html`: self-contained printable page, escaped and offline.
15
+
16
+ ### Changed
17
+
18
+ - Panels show the channel; JSON records carry it; real-corpus validation confirms text lures keep
19
+ their delivery route.
20
+
8
21
  ## 0.7.0 - 2026-11-02
9
22
 
10
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scamscript
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Reverse-engineer scam scripts and vishing transcripts into structured counter-playbooks
5
5
  Project-URL: Homepage, https://github.com/sjdsjadjajdjsa/scamscript
6
6
  Project-URL: Repository, https://github.com/sjdsjadjajdjsa/scamscript
@@ -111,7 +111,7 @@ Each script renders as a colour-coded panel:
111
111
  ╰────────────────────────────────── 7 red flags ───────────────────────────────╯
112
112
  ```
113
113
 
114
- Fields: `attack_type`, `persona`, `opening_hook`, `trust_building_moves`, `urgency_tactics`,
114
+ Fields: `attack_type`, `channel`, `persona`, `opening_hook`, `trust_building_moves`, `urgency_tactics`,
115
115
  `escalation_ladder`, `target_demographics`, `red_flags`, `defense_summary`, `sophistication_score`.
116
116
 
117
117
  The panel also carries a **quote check**: every quoted claim in `opening_hook` and `red_flags` is
@@ -135,7 +135,7 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
135
135
 
136
136
  Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
137
137
  `--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
138
- `--show-duplicates`, `--type`, `--language`, `--force`, `--no-color`. `--json` puts one object per script on stdout and all
138
+ `--show-duplicates`, `--type`, `--channel`, `--language`, `--force`, `--no-color`. `--json` puts one object per script on stdout and all
139
139
  Rich output on stderr, so it pipes:
140
140
 
141
141
  ```bash
@@ -178,7 +178,15 @@ per slug above the ranking, and `--type` filters to the ones you ask for:
178
178
  scamscript batch corpus/ --type tech-support,refund --output report.md
179
179
  ```
180
180
 
181
- Briefings group the same way. Build one from a finished run:
181
+ Delivery is tracked separately from theme. `channel` is one of `vishing`, `smishing`, `email` or
182
+ `other`, so a delivery-themed text lure is `delivery` + `smishing` rather than losing one of the two.
183
+ `--channel` filters on it:
184
+
185
+ ```bash
186
+ scamscript batch corpus/real --channel smishing --output texts.md
187
+ ```
188
+
189
+ Briefings group by type but accept `--channel` too. Build one from a finished run:
182
190
 
183
191
  ```bash
184
192
  scamscript brief --type tech-support --output briefing.md
@@ -238,6 +246,15 @@ Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (o
238
246
  a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
239
247
  bounded. Directories are `0700` and log files `0600`.
240
248
 
249
+ ## Printable briefings
250
+
251
+ `brief --html` writes a self-contained page with inline CSS and escaped text, for handing to people
252
+ who will not open a terminal.
253
+
254
+ ```bash
255
+ scamscript brief --type tech-support --html --output handout.html
256
+ ```
257
+
241
258
  ## Paths
242
259
 
243
260
  `--file` and `--dir` resolve symlinks, a recursive walk survives a symlink loop, and a file that
@@ -318,6 +335,8 @@ docs/ calibration record, sample report, banner, demo
318
335
  - Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
319
336
  - Type slugs are keyword-matched, so an unusual description falls to `other` rather than inventing one.
320
337
  - The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
338
+ - `channel` is inferred from the model's answer or, failing that, from keywords; a script with no
339
+ channel words falls to `other`.
321
340
  - The prompt asks the model to answer in the script's own language and `--language` overrides
322
341
  detection, but the type keywords are English: a non-English script often falls to `other`.
323
342
  - Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
@@ -75,7 +75,7 @@ Each script renders as a colour-coded panel:
75
75
  ╰────────────────────────────────── 7 red flags ───────────────────────────────╯
76
76
  ```
77
77
 
78
- Fields: `attack_type`, `persona`, `opening_hook`, `trust_building_moves`, `urgency_tactics`,
78
+ Fields: `attack_type`, `channel`, `persona`, `opening_hook`, `trust_building_moves`, `urgency_tactics`,
79
79
  `escalation_ladder`, `target_demographics`, `red_flags`, `defense_summary`, `sophistication_score`.
80
80
 
81
81
  The panel also carries a **quote check**: every quoted claim in `opening_hook` and `red_flags` is
@@ -99,7 +99,7 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
99
99
 
100
100
  Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
101
101
  `--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
102
- `--show-duplicates`, `--type`, `--language`, `--force`, `--no-color`. `--json` puts one object per script on stdout and all
102
+ `--show-duplicates`, `--type`, `--channel`, `--language`, `--force`, `--no-color`. `--json` puts one object per script on stdout and all
103
103
  Rich output on stderr, so it pipes:
104
104
 
105
105
  ```bash
@@ -142,7 +142,15 @@ per slug above the ranking, and `--type` filters to the ones you ask for:
142
142
  scamscript batch corpus/ --type tech-support,refund --output report.md
143
143
  ```
144
144
 
145
- Briefings group the same way. Build one from a finished run:
145
+ Delivery is tracked separately from theme. `channel` is one of `vishing`, `smishing`, `email` or
146
+ `other`, so a delivery-themed text lure is `delivery` + `smishing` rather than losing one of the two.
147
+ `--channel` filters on it:
148
+
149
+ ```bash
150
+ scamscript batch corpus/real --channel smishing --output texts.md
151
+ ```
152
+
153
+ Briefings group by type but accept `--channel` too. Build one from a finished run:
146
154
 
147
155
  ```bash
148
156
  scamscript brief --type tech-support --output briefing.md
@@ -202,6 +210,15 @@ Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (o
202
210
  a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
203
211
  bounded. Directories are `0700` and log files `0600`.
204
212
 
213
+ ## Printable briefings
214
+
215
+ `brief --html` writes a self-contained page with inline CSS and escaped text, for handing to people
216
+ who will not open a terminal.
217
+
218
+ ```bash
219
+ scamscript brief --type tech-support --html --output handout.html
220
+ ```
221
+
205
222
  ## Paths
206
223
 
207
224
  `--file` and `--dir` resolve symlinks, a recursive walk survives a symlink loop, and a file that
@@ -282,6 +299,8 @@ docs/ calibration record, sample report, banner, demo
282
299
  - Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
283
300
  - Type slugs are keyword-matched, so an unusual description falls to `other` rather than inventing one.
284
301
  - The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
302
+ - `channel` is inferred from the model's answer or, failing that, from keywords; a script with no
303
+ channel words falls to `other`.
285
304
  - The prompt asks the model to answer in the script's own language and `--language` overrides
286
305
  detection, but the type keywords are English: a non-English script often falls to `other`.
287
306
  - Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
@@ -35,3 +35,10 @@ Ten files, `--concurrency 3 --redact`, 2026-11-02, primary model:
35
35
  the model described the pattern instead of quoting a line.
36
36
 
37
37
  Recorded cost for the full directory: about two cents.
38
+
39
+ ## Channel split, same day
40
+
41
+ A third run added the `channel` field. All nine call transcripts type as `vishing`; the USPS message
42
+ is `delivery` + `smishing` instead of one erasing the other. The Medicare call again needed one
43
+ repair, and the bank call produced three paraphrased quotes this time against zero the run before:
44
+ quote drift varies by draw, which is why the checker flags rather than rewrites.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "scamscript"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Reverse-engineer scam scripts and vishing transcripts into structured counter-playbooks"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """scamscript: reverse-engineering analyzer for scam call scripts."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.8.0"
@@ -29,6 +29,7 @@ class Outcome:
29
29
  skipped: bool = False
30
30
  fingerprint: str = ""
31
31
  attack_slug: str = ""
32
+ channel: str = "other"
32
33
  language: str = "unknown"
33
34
  duplicate_of: str | None = None
34
35
  issues: list[verify.Issue] | None = None
@@ -47,6 +48,7 @@ class Outcome:
47
48
  "skipped": self.skipped,
48
49
  "fingerprint": self.fingerprint,
49
50
  "attack_slug": self.attack_slug,
51
+ "channel": self.channel,
50
52
  "language": self.language,
51
53
  "duplicate_of": self.duplicate_of,
52
54
  "attempts": self.attempts,
@@ -130,6 +132,11 @@ def analyze_script(
130
132
  outcome.breakdown = parse_breakdown(visible)
131
133
  outcome.error = None
132
134
  outcome.attack_slug = taxonomy.slugify(outcome.breakdown.attack_type)
135
+ outcome.channel = (
136
+ outcome.breakdown.channel
137
+ if outcome.breakdown.channel != "other"
138
+ else taxonomy.channel_of(outcome.breakdown.attack_type, outcome.breakdown.opening_hook)
139
+ )
133
140
  if verify_quotes:
134
141
  outcome.issues = verify.verify_breakdown(outcome.breakdown, text)
135
142
  outcome.verification = verify.summarize(outcome.issues)
@@ -0,0 +1,178 @@
1
+ """Turn a recorded run into a one-page defence briefing for trainers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from html import escape
6
+
7
+ from scamscript import taxonomy
8
+ from scamscript.report import ReportEntry
9
+
10
+ MAX_FLAGS = 6
11
+ MAX_PRESSURE = 4
12
+ MAX_COUNTERS = 4
13
+
14
+
15
+ def _unique(values: list[str], limit: int) -> list[str]:
16
+ seen: set[str] = set()
17
+ out: list[str] = []
18
+ for value in values:
19
+ key = value.strip().lower()
20
+ if not key or key in seen:
21
+ continue
22
+ seen.add(key)
23
+ out.append(value.strip())
24
+ if len(out) >= limit:
25
+ break
26
+ return out
27
+
28
+
29
+ def groups_for(
30
+ entries: list[ReportEntry],
31
+ only_types: set[str] | None = None,
32
+ only_channels: set[str] | None = None,
33
+ ) -> dict[str, list[ReportEntry]]:
34
+ groups: dict[str, list[ReportEntry]] = {}
35
+ for entry in entries:
36
+ if not entry.ok or not entry.breakdown:
37
+ continue
38
+ slug = entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)
39
+ if only_types and slug not in only_types:
40
+ continue
41
+ if only_channels and entry.channel not in only_channels:
42
+ continue
43
+ groups.setdefault(slug, []).append(entry)
44
+ return groups
45
+
46
+
47
+ def _parts(group: list[ReportEntry]) -> tuple[list[str], list[str], list[str]]:
48
+ flags = _unique([flag for entry in group for flag in entry.breakdown.red_flags], MAX_FLAGS)
49
+ pressure = _unique(
50
+ [
51
+ item
52
+ for entry in group
53
+ for item in entry.breakdown.urgency_tactics + entry.breakdown.escalation_ladder
54
+ ],
55
+ MAX_PRESSURE,
56
+ )
57
+ counters = _unique([entry.breakdown.defense_summary for entry in group], MAX_COUNTERS)
58
+ return flags, pressure, counters
59
+
60
+
61
+ def build_brief(
62
+ entries: list[ReportEntry],
63
+ run_id: str,
64
+ only_types: set[str] | None = None,
65
+ only_channels: set[str] | None = None,
66
+ ) -> str:
67
+ groups = groups_for(entries, only_types, only_channels)
68
+
69
+ lines = ["# Scam defence briefing", ""]
70
+ lines.append(f"Built from run `{run_id}`. Every claim comes from the analyzed scripts.")
71
+ lines.append("")
72
+ if not groups:
73
+ lines.append("_No matching successful analyses in this run._")
74
+ return "\n".join(lines) + "\n"
75
+
76
+ lines.append("Use this in order: the flags are what a target can notice, the counters are what")
77
+ lines.append("they should do instead of complying.")
78
+ lines.append("")
79
+
80
+ ordered = sorted(groups.items(), key=lambda item: (-len(item[1]), item[0]))
81
+ for slug, group in ordered:
82
+ scores = [entry.breakdown.sophistication_score for entry in group]
83
+ flags, pressure, counters = _parts(group)
84
+
85
+ lines.append(f"## {taxonomy.label(slug)} (`{slug}`)")
86
+ lines.append("")
87
+ lines.append(f"{len(group)} script(s), mean sophistication {sum(scores) / len(scores):.1f}/10.")
88
+ lines.append("")
89
+ lines.append("### What to notice")
90
+ lines.append("")
91
+ lines.extend(f"- {flag}" for flag in flags or ["_Nothing recorded._"])
92
+ lines.append("")
93
+ if pressure:
94
+ lines.append("### How pressure is applied")
95
+ lines.append("")
96
+ lines.extend(f"- {item}" for item in pressure)
97
+ lines.append("")
98
+ lines.append("### What to do")
99
+ lines.append("")
100
+ lines.extend(f"- {counter}" for counter in counters or ["_Nothing recorded._"])
101
+ lines.append("")
102
+ lines.append("### Sources")
103
+ lines.append("")
104
+ lines.extend(f"- `{entry.name}`" for entry in group)
105
+ lines.append("")
106
+
107
+ lines.append("---")
108
+ lines.append("")
109
+ lines.append(
110
+ "_Model-produced reading of captured scripts, for security research and fraud-awareness "
111
+ "training only._"
112
+ )
113
+ lines.append("")
114
+ return "\n".join(lines)
115
+
116
+
117
+ STYLE = """
118
+ :root { color-scheme: light dark; }
119
+ body { font: 16px/1.55 system-ui, sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; }
120
+ h1 { font-size: 1.6rem; } h2 { font-size: 1.2rem; margin-top: 2rem; border-top: 1px solid #8884; padding-top: 1rem; }
121
+ h3 { font-size: .95rem; text-transform: uppercase; letter-spacing: .04em; margin-bottom: .3rem; }
122
+ ul { margin-top: .2rem; } .meta { color: #777; } .slug { font-family: ui-monospace, monospace; color: #777; }
123
+ footer { margin-top: 2rem; color: #777; font-size: .85rem; }
124
+ """
125
+
126
+
127
+ def build_brief_html(
128
+ entries: list[ReportEntry],
129
+ run_id: str,
130
+ only_types: set[str] | None = None,
131
+ only_channels: set[str] | None = None,
132
+ ) -> str:
133
+ groups = groups_for(entries, only_types, only_channels)
134
+ out = [
135
+ "<!DOCTYPE html>",
136
+ '<html lang="en"><head><meta charset="utf-8">',
137
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
138
+ "<title>Scam defence briefing</title>",
139
+ f"<style>{STYLE}</style></head><body>",
140
+ "<h1>Scam defence briefing</h1>",
141
+ f'<p class="meta">Built from run <code>{escape(run_id)}</code>. '
142
+ "Every claim comes from the analyzed scripts.</p>",
143
+ ]
144
+ if not groups:
145
+ out.append("<p><em>No matching successful analyses in this run.</em></p>")
146
+ else:
147
+ out.append(
148
+ "<p>The flags are what a target can notice. The counters are what to do instead of "
149
+ "complying.</p>"
150
+ )
151
+ for slug, group in sorted(groups.items(), key=lambda item: (-len(item[1]), item[0])):
152
+ scores = [entry.breakdown.sophistication_score for entry in group]
153
+ flags, pressure, counters = _parts(group)
154
+ out.append("<section>")
155
+ out.append(
156
+ f"<h2>{escape(taxonomy.label(slug))} <span class=\"slug\">({escape(slug)})</span></h2>"
157
+ )
158
+ out.append(
159
+ f'<p class="meta">{len(group)} script(s), mean sophistication '
160
+ f"{sum(scores) / len(scores):.1f}/10.</p>"
161
+ )
162
+ out.append("<h3>What to notice</h3><ul>")
163
+ out.extend(f"<li>{escape(flag)}</li>" for flag in flags or ["Nothing recorded."])
164
+ out.append("</ul>")
165
+ if pressure:
166
+ out.append("<h3>How pressure is applied</h3><ul>")
167
+ out.extend(f"<li>{escape(item)}</li>" for item in pressure)
168
+ out.append("</ul>")
169
+ out.append("<h3>What to do</h3><ul>")
170
+ out.extend(f"<li>{escape(counter)}</li>" for counter in counters or ["Nothing recorded."])
171
+ out.append("</ul><h3>Sources</h3><ul>")
172
+ out.extend(f"<li><code>{escape(entry.name)}</code></li>" for entry in group)
173
+ out.append("</ul></section>")
174
+ out.append(
175
+ "<footer>Model-produced reading of captured scripts. Security research and "
176
+ "fraud-awareness training only.</footer></body></html>"
177
+ )
178
+ return "\n".join(out) + "\n"
@@ -241,6 +241,7 @@ def _run(
241
241
  output: Path | None,
242
242
  top: int | None,
243
243
  types: list[str] | None = None,
244
+ channels: list[str] | None = None,
244
245
  force: bool = False,
245
246
  ) -> None:
246
247
  console = state.console
@@ -429,6 +430,17 @@ def _run(
429
430
  console.print(
430
431
  f"[yellow]note[/yellow] unknown type(s): {', '.join(sorted(unknown))}"
431
432
  )
433
+ only_channels = {
434
+ chunk.strip().lower()
435
+ for value in channels or []
436
+ for chunk in value.split(",")
437
+ if chunk.strip()
438
+ }
439
+ invalid_channels = only_channels - set(taxonomy.CHANNELS)
440
+ if invalid_channels:
441
+ console.print(
442
+ f"[yellow]note[/yellow] unknown channel(s): {', '.join(sorted(invalid_channels))}"
443
+ )
432
444
  collapsed = reporting.collapse(report_entries) if state.dedupe else report_entries
433
445
 
434
446
  if batch_mode and (successes or skipped):
@@ -448,6 +460,7 @@ def _run(
448
460
  command=command,
449
461
  model=settings.model,
450
462
  only_types=only_types,
463
+ only_channels=only_channels,
451
464
  )
452
465
  written = _emit_report(
453
466
  console, markdown, output, json_mode=state.json_output, force=force
@@ -527,6 +540,9 @@ def analyze(
527
540
  None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
528
541
  ),
529
542
  force: bool = typer.Option(False, "--force", help="Overwrite an existing output file."),
543
+ channels: list[str] | None = typer.Option(
544
+ None, "--channel", help="Only report these channels (vishing, smishing, email, other)."
545
+ ),
530
546
  ) -> None:
531
547
  """Analyze a single script file, or a directory with --batch."""
532
548
  state = _context(ctx)
@@ -547,6 +563,7 @@ def analyze(
547
563
  output=output,
548
564
  top=top,
549
565
  types=types,
566
+ channels=channels,
550
567
  force=force,
551
568
  )
552
569
 
@@ -564,6 +581,9 @@ def batch(
564
581
  None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
565
582
  ),
566
583
  force: bool = typer.Option(False, "--force", help="Overwrite an existing output file."),
584
+ channels: list[str] | None = typer.Option(
585
+ None, "--channel", help="Only report these channels (vishing, smishing, email, other)."
586
+ ),
567
587
  ) -> None:
568
588
  """Analyze a directory and rank every script by sophistication."""
569
589
  state = _context(ctx)
@@ -580,6 +600,7 @@ def batch(
580
600
  output=output,
581
601
  top=top,
582
602
  types=types,
603
+ channels=channels,
583
604
  force=force,
584
605
  )
585
606
 
@@ -596,6 +617,9 @@ def report( # noqa: A001
596
617
  None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
597
618
  ),
598
619
  force: bool = typer.Option(False, "--force", help="Overwrite an existing output file."),
620
+ channels: list[str] | None = typer.Option(
621
+ None, "--channel", help="Only report these channels (vishing, smishing, email, other)."
622
+ ),
599
623
  ) -> None:
600
624
  """Regenerate the aggregated report from a past run's log."""
601
625
  state = _context(ctx)
@@ -623,6 +647,12 @@ def report( # noqa: A001
623
647
  command=str(run.get("command") or "report"),
624
648
  model=run.get("model"),
625
649
  only_types=taxonomy.parse_filter(types),
650
+ only_channels={
651
+ chunk.strip().lower()
652
+ for value in channels or []
653
+ for chunk in value.split(",")
654
+ if chunk.strip()
655
+ },
626
656
  )
627
657
  _emit_report(state.console, markdown, output, force=force)
628
658
 
@@ -673,9 +703,15 @@ def brief(
673
703
  types: list[str] | None = typer.Option(
674
704
  None, "--type", help="Only brief these type slugs (repeatable or comma-separated)."
675
705
  ),
706
+ channels: list[str] | None = typer.Option(
707
+ None, "--channel", help="Only brief these channels (vishing, smishing, email, other)."
708
+ ),
676
709
  output: Path | None = typer.Option(None, "--output", "-o", help="Write the briefing here."),
677
710
  logs_dir: Path | None = typer.Option(None, "--logs-dir", help="Override the log directory."),
678
711
  force: bool = typer.Option(False, "--force", help="Overwrite an existing output file."),
712
+ as_html: bool = typer.Option(
713
+ False, "--html", help="Emit a standalone HTML page instead of markdown."
714
+ ),
679
715
  ) -> None:
680
716
  """Build a one-page defence briefing from a past run."""
681
717
  state = _context(ctx)
@@ -694,10 +730,17 @@ def brief(
694
730
  return
695
731
 
696
732
  entries = reporting.entries_from_run(run)
697
- markdown = briefing.build_brief(
733
+ builder = briefing.build_brief_html if as_html else briefing.build_brief
734
+ markdown = builder(
698
735
  entries,
699
736
  run_id=str(run["run_id"]),
700
737
  only_types=taxonomy.parse_filter(types),
738
+ only_channels={
739
+ chunk.strip().lower()
740
+ for value in channels or []
741
+ for chunk in value.split(",")
742
+ if chunk.strip()
743
+ },
701
744
  )
702
745
  _emit_report(state.console, markdown, output, force=force)
703
746
  if output:
@@ -63,7 +63,7 @@ class ChatClient:
63
63
  "Authorization": f"Bearer {api_key}",
64
64
  "Content-Type": "application/json",
65
65
  "Accept": "application/json",
66
- "User-Agent": "scamscript/0.7.0",
66
+ "User-Agent": "scamscript/0.8.0",
67
67
  },
68
68
  )
69
69
 
@@ -33,6 +33,7 @@ class ScriptBreakdown(BaseModel):
33
33
  model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
34
34
 
35
35
  attack_type: str
36
+ channel: str = "other"
36
37
  persona: str
37
38
  opening_hook: str
38
39
  trust_building_moves: list[str]
@@ -76,6 +77,22 @@ class ScriptBreakdown(BaseModel):
76
77
  return out
77
78
  return [str(value)]
78
79
 
80
+ @field_validator("channel", mode="before")
81
+ @classmethod
82
+ def _channel(cls, value: Any) -> str:
83
+ text = str(value or "").strip().lower()
84
+ aliases = {
85
+ "call": "vishing",
86
+ "phone": "vishing",
87
+ "voice": "vishing",
88
+ "text": "smishing",
89
+ "sms": "smishing",
90
+ "whatsapp": "smishing",
91
+ "mail": "email",
92
+ }
93
+ text = aliases.get(text, text)
94
+ return text if text in {"vishing", "smishing", "email", "other"} else "other"
95
+
79
96
  @field_validator("sophistication_score", mode="before")
80
97
  @classmethod
81
98
  def _coerce_score(cls, value: Any) -> int:
@@ -22,6 +22,7 @@ markdown. It must validate against this schema:
22
22
 
23
23
  {
24
24
  "attack_type": "string: the fraud family, e.g. vishing, smishing, romance scam, tech support scam, government impersonation, refund scam, crypto investment scam",
25
+ "channel": "string: the delivery route, exactly one of vishing, smishing, email, other",
25
26
  "persona": "string: the identity, authority, and tone the caller adopts, including any claimed employer, department, or credentials",
26
27
  "opening_hook": "string: the literal first line or lure that grabs attention, quoted from the script when present",
27
28
  "trust_building_moves": ["string: each credibility move in order, e.g. badge numbers, case IDs, hold music, spoofed caller ID, callback numbers, jargon"],
@@ -34,6 +35,7 @@ markdown. It must validate against this schema:
34
35
  }
35
36
 
36
37
  Rules:
38
+ - channel is about delivery, not theme: a delivery-themed text message is channel smishing, a phone call is vishing.
37
39
  - opening_hook must quote the script verbatim when it has dialogue.
38
40
  - Every list must hold at least three concrete items unless the artifact truly cannot support that, in which case give what exists.
39
41
  - escalation_ladder must be ordered and must end with the walk-away path.
@@ -118,6 +118,7 @@ def render_breakdown(
118
118
  style=SECTION_COLORS["attack_type"],
119
119
  )
120
120
  attack.append(" ")
121
+ attack.append(f"VIA {breakdown.channel.upper()} ", style="dim")
121
122
  attack.append("SOPHISTICATION ", style="dim")
122
123
  attack.append(
123
124
  f"{breakdown.sophistication_score}/10",
@@ -33,6 +33,7 @@ class ReportEntry:
33
33
  skipped: bool = False
34
34
  fingerprint: str = ""
35
35
  attack_slug: str = ""
36
+ channel: str = "other"
36
37
  duplicates: list[str] | None = None
37
38
  verification: dict[str, int] | None = None
38
39
  unverified: list[dict[str, str]] | None = None
@@ -50,6 +51,7 @@ def entry_from_outcome(outcome: Any) -> ReportEntry:
50
51
  skipped=getattr(outcome, "skipped", False),
51
52
  fingerprint=getattr(outcome, "fingerprint", ""),
52
53
  attack_slug=getattr(outcome, "attack_slug", ""),
54
+ channel=getattr(outcome, "channel", "other"),
53
55
  error=outcome.error,
54
56
  duration=getattr(outcome, "duration", 0.0),
55
57
  verification=getattr(outcome, "verification", None),
@@ -79,6 +81,7 @@ def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
79
81
  skipped=bool(record.get("skipped")),
80
82
  fingerprint=record.get("fingerprint") or "",
81
83
  attack_slug=record.get("attack_slug") or "",
84
+ channel=record.get("channel") or "other",
82
85
  duplicates=record.get("duplicates") or None,
83
86
  error=None if breakdown else record.get("error") or "no breakdown stored",
84
87
  duration=float(record.get("duration_s") or 0.0),
@@ -129,7 +132,10 @@ def build_markdown(
129
132
  command: str = "batch",
130
133
  model: str | None = None,
131
134
  only_types: set[str] | None = None,
135
+ only_channels: set[str] | None = None,
132
136
  ) -> str:
137
+ if only_channels:
138
+ entries = [entry for entry in entries if not entry.ok or entry.channel in only_channels]
133
139
  if only_types:
134
140
  entries = [
135
141
  entry
@@ -177,12 +183,13 @@ def build_markdown(
177
183
  lines.append("")
178
184
  lines.append("## Ranking by sophistication")
179
185
  lines.append("")
180
- lines.append("| Rank | Script | Type | Sophistication | Red flags | Unverified | Copies |")
181
- lines.append("| ---: | --- | --- | ---: | ---: | ---: | ---: |")
186
+ lines.append("| Rank | Script | Type | Channel | Sophistication | Red flags | Unverified | Copies |")
187
+ lines.append("| ---: | --- | --- | --- | ---: | ---: | ---: | ---: |")
182
188
  for position, entry in enumerate(ok, start=1):
183
189
  lines.append(
184
190
  f"| {position} | {_cell(entry.name)} | "
185
191
  f"`{entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)}` | "
192
+ f"`{entry.channel}` | "
186
193
  f"{entry.breakdown.sophistication_score}/10 | {entry.breakdown.flag_count} | "
187
194
  f"{len(entry.unverified or [])} | {1 + len(entry.duplicates or [])} |"
188
195
  )
@@ -225,6 +232,7 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
225
232
  lines.append(
226
233
  f"- **Type slug:** `{entry.attack_slug or taxonomy.slugify(breakdown.attack_type)}`"
227
234
  )
235
+ lines.append(f"- **Channel:** `{entry.channel}`")
228
236
  lines.append(f"- **Sophistication:** {breakdown.sophistication_score}/10")
229
237
  lines.append(f"- **Red flags observed:** {breakdown.flag_count}")
230
238
  if entry.duplicates:
@@ -24,6 +24,13 @@ RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
24
24
  ("vishing", ("vishing", "phone call", "cold call", "robocall", "phone")),
25
25
  )
26
26
 
27
+ CHANNELS = ("vishing", "smishing", "email", "other")
28
+ CHANNEL_RULES = (
29
+ ("vishing", ("vishing", "call", "phone", "robocall", "cold call", "landline")),
30
+ ("smishing", ("smishing", "sms", "text message", "text-message", "whatsapp")),
31
+ ("email", ("email", "e-mail", "phishing email")),
32
+ )
33
+
27
34
  SLUGS = tuple(slug for slug, _ in RULES) + (OTHER,)
28
35
  NON_WORD = re.compile(r"[^a-z0-9]+")
29
36
  MATCHERS: tuple[tuple[str, re.Pattern[str]], ...] = tuple(
@@ -41,6 +48,15 @@ def slugify(attack_type: str) -> str:
41
48
  return OTHER
42
49
 
43
50
 
51
+ def channel_of(*texts: str) -> str:
52
+ """Infer the delivery channel when the model did not supply one."""
53
+ text = NON_WORD.sub(" ", " ".join(texts).lower())
54
+ for channel, needles in CHANNEL_RULES:
55
+ if any(re.search(r"\b" + re.escape(n) + r"\b", text) for n in needles):
56
+ return channel
57
+ return "other"
58
+
59
+
44
60
  def unknown(values: list[str] | None) -> set[str]:
45
61
  """Raw filter tokens that are not a known slug."""
46
62
  if not values:
@@ -55,3 +55,22 @@ def test_brief_caps_list_length():
55
55
  flags = [f"flag {i}" for i in range(10)]
56
56
  out = build_brief([entry("a", "vishing", flags, "c")], run_id="r")
57
57
  assert out.count("- flag ") == 6
58
+
59
+
60
+ def test_html_brief_structure_and_escaping():
61
+ from scamscript.brief import build_brief_html
62
+
63
+ out = build_brief_html(
64
+ [entry("a<b>.txt", "vishing", ["<script>alert(1)</script>"], "hang up & verify")], run_id="r"
65
+ )
66
+ assert out.startswith("<!DOCTYPE html>")
67
+ assert "<script>alert(1)</script>" not in out
68
+ assert "&lt;script&gt;alert(1)&lt;/script&gt;" in out
69
+ assert "hang up &amp; verify" in out
70
+ assert "<title>Scam defence briefing</title>" in out
71
+
72
+
73
+ def test_html_brief_empty():
74
+ from scamscript.brief import build_brief_html
75
+
76
+ assert "No matching successful analyses" in build_brief_html([], run_id="r")
@@ -146,15 +146,13 @@ def test_batch_top_limits_report(home, tmp_path, fake):
146
146
  assert "## 2." not in markdown
147
147
 
148
148
 
149
- def test_failure_exits_nonzero(home, tmp_path, fake):
149
+ def test_failure_exits_nonzero(home, tmp_path, fake, monkeypatch):
150
150
  (tmp_path / "a.txt").write_text("CALLER: x", encoding="utf-8")
151
151
  fake.default = None
152
- original = FakeClient.complete
153
- fake.complete = lambda self, messages, **kwargs: "not json"
152
+ monkeypatch.setattr(FakeClient, "complete", lambda self, messages, **kwargs: "not json")
154
153
  result = runner.invoke(cli.app, ["--api-key", "k", "analyze", "--file", str(tmp_path / "a.txt")])
155
154
  assert result.exit_code == 1
156
155
  assert "FAILED" in result.stdout
157
- fake.complete = original
158
156
 
159
157
 
160
158
  def test_report_replays_logged_run(home, tmp_path, fake):
@@ -230,7 +228,7 @@ def test_dry_run_without_endpoint(home, tmp_path, monkeypatch):
230
228
  def test_version(home):
231
229
  result = runner.invoke(cli.app, ["--version"])
232
230
  assert result.exit_code == 0
233
- assert result.stdout.strip() == "scamscript 0.7.0"
231
+ assert result.stdout.strip() == "scamscript 0.8.0"
234
232
 
235
233
 
236
234
  def test_json_output_is_one_object_per_script(home, tmp_path, fake):
@@ -480,14 +478,14 @@ def test_unknown_type_warns(home, tmp_path, fake):
480
478
  assert "unknown type" in result.stdout
481
479
 
482
480
 
483
- def test_language_override_reaches_prompt(home, tmp_path, fake):
481
+ def test_language_override_reaches_prompt(home, tmp_path, fake, monkeypatch):
484
482
  seen = {}
485
483
 
486
484
  def complete(self, messages, **kwargs):
487
485
  seen["prompt"] = messages[-1]["content"]
488
486
  return json.dumps(breakdown())
489
487
 
490
- fake.complete = complete
488
+ monkeypatch.setattr(FakeClient, "complete", complete)
491
489
  script = tmp_path / "a.txt"
492
490
  script.write_text("CALLER: hola", encoding="utf-8")
493
491
  result = runner.invoke(
@@ -495,4 +493,50 @@ def test_language_override_reaches_prompt(home, tmp_path, fake):
495
493
  )
496
494
  assert result.exit_code == 0
497
495
  assert "'es'" in seen["prompt"]
498
- fake.complete = FakeClient.complete
496
+
497
+
498
+ def test_channel_filter_and_unknown_warning(home, tmp_path, fake):
499
+ (tmp_path / "a.txt").write_text("CALLER: call me", encoding="utf-8")
500
+ (tmp_path / "b.txt").write_text("CALLER: text me", encoding="utf-8")
501
+ vish = breakdown()
502
+ vish["channel"] = "vishing"
503
+ smish = breakdown()
504
+ smish["channel"] = "smishing"
505
+ fake.replies = {"a.txt": vish, "b.txt": smish}
506
+ out = tmp_path / "r.md"
507
+ result = runner.invoke(
508
+ cli.app, ["--api-key", "k", "batch", str(tmp_path), "--channel", "vishing", "--output", str(out)]
509
+ )
510
+ assert result.exit_code == 0
511
+ assert "a.txt" in out.read_text() and "b.txt" not in out.read_text()
512
+
513
+
514
+ def test_unknown_channel_warns(home, tmp_path, fake):
515
+ (tmp_path / "a.txt").write_text("CALLER: x", encoding="utf-8")
516
+ result = runner.invoke(cli.app, ["--api-key", "k", "batch", str(tmp_path), "--channel", "carrier-pigeon"])
517
+ assert result.exit_code == 0
518
+ assert "unknown channel" in result.stdout
519
+
520
+
521
+ def test_brief_html_output(home, tmp_path, fake):
522
+ script = tmp_path / "a.txt"
523
+ script.write_text("CALLER: x", encoding="utf-8")
524
+ runner.invoke(cli.app, ["--api-key", "k", "analyze", "--file", str(script)])
525
+ out = tmp_path / "b.html"
526
+ result = runner.invoke(cli.app, ["brief", "--html", "--output", str(out)])
527
+ assert result.exit_code == 0
528
+ assert out.read_text().startswith("<!DOCTYPE html>")
529
+
530
+
531
+ def test_brief_refuses_overwrite_without_force(home, tmp_path, fake):
532
+ script = tmp_path / "a.txt"
533
+ script.write_text("CALLER: x", encoding="utf-8")
534
+ runner.invoke(cli.app, ["--api-key", "k", "analyze", "--file", str(script)])
535
+ out = tmp_path / "b.md"
536
+ out.write_text("old", encoding="utf-8")
537
+ refused = runner.invoke(cli.app, ["brief", "--output", str(out)])
538
+ assert refused.exit_code == 1
539
+ assert "pass --force" in refused.stdout
540
+ forced = runner.invoke(cli.app, ["brief", "--output", str(out), "--force"])
541
+ assert forced.exit_code == 0
542
+ assert "Scam defence briefing" in out.read_text()
@@ -31,7 +31,7 @@ def test_markdown_has_summary_and_details():
31
31
  ]
32
32
  markdown = build_markdown(entries, run_id="run-1", command="batch", model="m")
33
33
  assert "# Scam script breakdown report" in markdown
34
- assert "| 1 | a.txt | `vishing` | 9/10 | 1 | 0 | 1 |" in markdown
34
+ assert "| 1 | a.txt | `vishing` | `other` | 9/10 | 1 | 0 | 1 |" in markdown
35
35
  assert "## Failures" in markdown
36
36
  assert "bad json" in markdown
37
37
  assert "### Defense summary" in markdown
@@ -90,3 +90,12 @@ def test_type_filter_keeps_only_allowed_and_failures():
90
90
  assert "a.txt" in markdown
91
91
  assert "b.txt" not in markdown
92
92
  assert "bad.txt" in markdown
93
+
94
+
95
+ def test_channel_filter():
96
+ entries = [
97
+ ReportEntry(name="a.txt", source="a", breakdown=breakdown(5), channel="vishing"),
98
+ ReportEntry(name="b.txt", source="b", breakdown=breakdown(5), channel="smishing"),
99
+ ]
100
+ markdown = build_markdown(entries, run_id="r", only_channels={"vishing"})
101
+ assert "a.txt" in markdown and "b.txt" not in markdown
@@ -54,3 +54,25 @@ def test_parse_filter_accepts_comma_and_spaces():
54
54
  def test_label_known_and_fallback():
55
55
  assert label("bec") == "Business email compromise"
56
56
  assert label("odd") == "odd"
57
+
58
+
59
+ @pytest.mark.parametrize(
60
+ "texts,channel",
61
+ [
62
+ (("USPS delivery smishing text",), "smishing"),
63
+ (("phone robocall",), "vishing"),
64
+ (("vendor invoice", "email"), "email"),
65
+ (("something",), "other"),
66
+ (("first", "second"), "other"),
67
+ ],
68
+ )
69
+ def test_channel_of(texts, channel):
70
+ from scamscript.taxonomy import channel_of
71
+
72
+ assert channel_of(*texts) == channel
73
+
74
+
75
+ def test_channel_matching_uses_word_boundaries():
76
+ from scamscript.taxonomy import channel_of
77
+
78
+ assert channel_of("rainfall") == "other"
@@ -1,95 +0,0 @@
1
- """Turn a recorded run into a one-page defence briefing for trainers."""
2
-
3
- from __future__ import annotations
4
-
5
- from scamscript import taxonomy
6
- from scamscript.report import ReportEntry
7
-
8
- MAX_FLAGS = 6
9
- MAX_PRESSURE = 4
10
- MAX_COUNTERS = 4
11
-
12
-
13
- def _unique(values: list[str], limit: int) -> list[str]:
14
- seen: set[str] = set()
15
- out: list[str] = []
16
- for value in values:
17
- key = value.strip().lower()
18
- if not key or key in seen:
19
- continue
20
- seen.add(key)
21
- out.append(value.strip())
22
- if len(out) >= limit:
23
- break
24
- return out
25
-
26
-
27
- def build_brief(
28
- entries: list[ReportEntry], run_id: str, only_types: set[str] | None = None
29
- ) -> str:
30
- groups: dict[str, list[ReportEntry]] = {}
31
- for entry in entries:
32
- if not entry.ok or not entry.breakdown:
33
- continue
34
- slug = entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)
35
- if only_types and slug not in only_types:
36
- continue
37
- groups.setdefault(slug, []).append(entry)
38
-
39
- lines = ["# Scam defence briefing", ""]
40
- lines.append(f"Built from run `{run_id}`. Every claim comes from the analyzed scripts.")
41
- lines.append("")
42
- if not groups:
43
- lines.append("_No matching successful analyses in this run._")
44
- return "\n".join(lines) + "\n"
45
-
46
- lines.append("Use this in order: the flags are what a target can notice, the counters are what")
47
- lines.append("they should do instead of complying.")
48
- lines.append("")
49
-
50
- ordered = sorted(groups.items(), key=lambda item: (-len(item[1]), item[0]))
51
- for slug, group in ordered:
52
- scores = [entry.breakdown.sophistication_score for entry in group]
53
- flags = _unique([flag for entry in group for flag in entry.breakdown.red_flags], MAX_FLAGS)
54
- pressure = _unique(
55
- [
56
- item
57
- for entry in group
58
- for item in entry.breakdown.urgency_tactics + entry.breakdown.escalation_ladder
59
- ],
60
- MAX_PRESSURE,
61
- )
62
- counters = _unique(
63
- [entry.breakdown.defense_summary for entry in group], MAX_COUNTERS
64
- )
65
-
66
- lines.append(f"## {taxonomy.label(slug)} (`{slug}`)")
67
- lines.append("")
68
- lines.append(f"{len(group)} script(s), mean sophistication {sum(scores) / len(scores):.1f}/10.")
69
- lines.append("")
70
- lines.append("### What to notice")
71
- lines.append("")
72
- lines.extend(f"- {flag}" for flag in flags or ["_Nothing recorded._"])
73
- lines.append("")
74
- if pressure:
75
- lines.append("### How pressure is applied")
76
- lines.append("")
77
- lines.extend(f"- {item}" for item in pressure)
78
- lines.append("")
79
- lines.append("### What to do")
80
- lines.append("")
81
- lines.extend(f"- {counter}" for counter in counters or ["_Nothing recorded._"])
82
- lines.append("")
83
- lines.append("### Sources")
84
- lines.append("")
85
- lines.extend(f"- `{entry.name}`" for entry in group)
86
- lines.append("")
87
-
88
- lines.append("---")
89
- lines.append("")
90
- lines.append(
91
- "_Model-produced reading of captured scripts, for security research and fraud-awareness "
92
- "training only._"
93
- )
94
- lines.append("")
95
- return "\n".join(lines)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes