scamscript 0.4.1__tar.gz → 0.6.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 (69) hide show
  1. {scamscript-0.4.1 → scamscript-0.6.0}/CHANGELOG.md +27 -0
  2. {scamscript-0.4.1 → scamscript-0.6.0}/PKG-INFO +33 -4
  3. {scamscript-0.4.1 → scamscript-0.6.0}/README.md +32 -3
  4. {scamscript-0.4.1 → scamscript-0.6.0}/pyproject.toml +1 -1
  5. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/__init__.py +1 -1
  6. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/analysis.py +11 -2
  7. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/cli.py +62 -8
  8. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/client.py +1 -1
  9. scamscript-0.6.0/src/scamscript/dedupe.py +29 -0
  10. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/render.py +2 -0
  11. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/report.py +73 -4
  12. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/store.py +19 -1
  13. scamscript-0.6.0/src/scamscript/taxonomy.py +77 -0
  14. scamscript-0.6.0/tests/fixtures/reprints/copy-header.txt +4 -0
  15. scamscript-0.6.0/tests/fixtures/reprints/copy-punctuation.txt +11 -0
  16. scamscript-0.6.0/tests/fixtures/reprints/near-twin.txt +5 -0
  17. scamscript-0.6.0/tests/fixtures/reprints/original.txt +4 -0
  18. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_cli.py +70 -1
  19. scamscript-0.6.0/tests/test_dedupe.py +60 -0
  20. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_report.py +14 -1
  21. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_store.py +10 -0
  22. scamscript-0.6.0/tests/test_taxonomy.py +51 -0
  23. {scamscript-0.4.1 → scamscript-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  24. {scamscript-0.4.1 → scamscript-0.6.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  25. {scamscript-0.4.1 → scamscript-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  26. {scamscript-0.4.1 → scamscript-0.6.0}/.github/SECURITY.md +0 -0
  27. {scamscript-0.4.1 → scamscript-0.6.0}/.github/pull_request_template.md +0 -0
  28. {scamscript-0.4.1 → scamscript-0.6.0}/.github/workflows/ci.yml +0 -0
  29. {scamscript-0.4.1 → scamscript-0.6.0}/.github/workflows/release.yml +0 -0
  30. {scamscript-0.4.1 → scamscript-0.6.0}/.gitignore +0 -0
  31. {scamscript-0.4.1 → scamscript-0.6.0}/CITATION.cff +0 -0
  32. {scamscript-0.4.1 → scamscript-0.6.0}/CODE_OF_CONDUCT.md +0 -0
  33. {scamscript-0.4.1 → scamscript-0.6.0}/CONTRIBUTING.md +0 -0
  34. {scamscript-0.4.1 → scamscript-0.6.0}/LICENSE +0 -0
  35. {scamscript-0.4.1 → scamscript-0.6.0}/docs/banner.svg +0 -0
  36. {scamscript-0.4.1 → scamscript-0.6.0}/docs/calibration.md +0 -0
  37. {scamscript-0.4.1 → scamscript-0.6.0}/docs/demo.gif +0 -0
  38. {scamscript-0.4.1 → scamscript-0.6.0}/docs/sample-report.md +0 -0
  39. {scamscript-0.4.1 → scamscript-0.6.0}/docs/social-preview.png +0 -0
  40. {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/fake_output_json.txt +0 -0
  41. {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/fence_escape.txt +0 -0
  42. {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/fixed_attack_type.txt +0 -0
  43. {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/non_json_demand.txt +0 -0
  44. {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/print_system_prompt.txt +0 -0
  45. {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/role_switch.txt +0 -0
  46. {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/score_override.txt +0 -0
  47. {scamscript-0.4.1 → scamscript-0.6.0}/examples/crude_robocall.txt +0 -0
  48. {scamscript-0.4.1 → scamscript-0.6.0}/examples/gov_impersonation_vishing.txt +0 -0
  49. {scamscript-0.4.1 → scamscript-0.6.0}/examples/prompt_injection_script.txt +0 -0
  50. {scamscript-0.4.1 → scamscript-0.6.0}/examples/romance_scam_sms.txt +0 -0
  51. {scamscript-0.4.1 → scamscript-0.6.0}/examples/sophisticated_wire_fraud.txt +0 -0
  52. {scamscript-0.4.1 → scamscript-0.6.0}/examples/tech_support_scam.txt +0 -0
  53. {scamscript-0.4.1 → scamscript-0.6.0}/scripts/calibrate.py +0 -0
  54. {scamscript-0.4.1 → scamscript-0.6.0}/scripts/injection_eval.py +0 -0
  55. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/__main__.py +0 -0
  56. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/config.py +0 -0
  57. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/ingest.py +0 -0
  58. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/models.py +0 -0
  59. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/prompts.py +0 -0
  60. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/redact.py +0 -0
  61. {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/verify.py +0 -0
  62. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_analysis.py +0 -0
  63. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_client.py +0 -0
  64. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_ingest.py +0 -0
  65. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_models.py +0 -0
  66. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_prompts.py +0 -0
  67. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_redact.py +0 -0
  68. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_render.py +0 -0
  69. {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_verify.py +0 -0
@@ -5,6 +5,33 @@ schema are the things that count as public API.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.6.0 - 2026-11-02
9
+
10
+ ### Added
11
+
12
+ - Closed-set attack type slugs, a per-type count and mean score table, and `--type` filtering with an
13
+ unknown-token warning. Raw descriptions are preserved.
14
+
15
+ ### Changed
16
+
17
+ - Panels show the slug beneath the attack type.
18
+
19
+ ### Security
20
+
21
+ - Log directories are created `0700` and log files `0600`, so captures are not world-readable.
22
+
23
+ ## 0.5.0 - 2026-11-02
24
+
25
+ ### Added
26
+
27
+ - Content fingerprints and duplicate folding: reprints collapse to the strongest copy in reports,
28
+ with a folded count and `duplicate_of` in JSON.
29
+ - `--dedupe/--no-dedupe` (on by default) and `--show-duplicates` to render every panel.
30
+
31
+ ### Changed
32
+
33
+ - Duplicate panels are replaced by a one-line pointer unless `--show-duplicates` is set.
34
+
8
35
  ## 0.4.1 - 2026-11-02
9
36
 
10
37
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scamscript
3
- Version: 0.4.1
3
+ Version: 0.6.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
@@ -133,7 +133,8 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
133
133
  | `prune` | Delete old logs |
134
134
 
135
135
  Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
136
- `--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--no-color`. `--json` puts one object per script on stdout and all
136
+ `--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
137
+ `--show-duplicates`, `--type`, `--no-color`. `--json` puts one object per script on stdout and all
137
138
  Rich output on stderr, so it pipes:
138
139
 
139
140
  ```bash
@@ -154,6 +155,33 @@ The cap reads `usage.cost` from the response, so it works only where the provide
154
155
  says so when none is sent. Spend is printed and logged per run. In-flight requests can overshoot by
155
156
  up to the concurrency, so keep headroom on a hard limit.
156
157
 
158
+ ## Attack types
159
+
160
+ `attack_type` is free prose, which is useful to read and useless to group. Every result also carries a
161
+ closed-set slug (`vishing`, `smishing`, `tech-support`, `refund`, `gov-impersonation`, `bec`,
162
+ `romance`, `crypto`, `investment`, `utility`, `delivery`, `other`). Reports show counts and mean scores
163
+ per slug above the ranking, and `--type` filters to the ones you ask for:
164
+
165
+ ```bash
166
+ scamscript batch corpus/ --type tech-support,refund --output report.md
167
+ ```
168
+
169
+ The raw phrase is always kept next to the slug. An unrecognised description lands in `other` and the
170
+ CLI names any filter token it did not recognise.
171
+
172
+ ## Duplicates
173
+
174
+ Corpora are full of reprints. Each script is fingerprinted on its body: headers, casing, punctuation
175
+ and whitespace are ignored, so copies collapse. The strongest copy is ranked and the rest are listed
176
+ under it; the report counts what was folded and JSON records carry `fingerprint` and `duplicate_of`.
177
+
178
+ ```bash
179
+ scamscript batch corpus/ --output report.md # folds by default
180
+ scamscript batch corpus/ --no-dedupe --show-duplicates
181
+ ```
182
+
183
+ Only byte-level twins fold. A copy with one changed account number stays separate, by design.
184
+
157
185
  ## How it works
158
186
 
159
187
  ```text
@@ -190,7 +218,7 @@ masking is best effort: names, case IDs and spelled-out digits survive it.
190
218
  Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (override with
191
219
  `--home` or `SCAMSCRIPT_HOME`). The API key is never written. Logs are the source for `report`, so
192
220
  a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
193
- bounded.
221
+ bounded. Directories are `0700` and log files `0600`.
194
222
 
195
223
  ## Install and configure
196
224
 
@@ -262,7 +290,8 @@ docs/ calibration record, sample report, banner, demo
262
290
  - Breakdowns are model readings; a bad prompt or a weak model produces confident nonsense. Read
263
291
  `opening_hook` and `red_flags` against the source before quoting anything.
264
292
  - Redaction is regex-based and will miss names and identifiers without a fixed shape.
265
- - One script per request; no cross-script deduplication yet, so reprints inflate a ranking.
293
+ - Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
294
+ - Type slugs are keyword-matched, so an unusual description falls to `other` rather than inventing one.
266
295
  - The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
267
296
  - Analysis is English-centric: the prompt and schema assume English artifacts.
268
297
  - Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
@@ -97,7 +97,8 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
97
97
  | `prune` | Delete old logs |
98
98
 
99
99
  Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
100
- `--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--no-color`. `--json` puts one object per script on stdout and all
100
+ `--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
101
+ `--show-duplicates`, `--type`, `--no-color`. `--json` puts one object per script on stdout and all
101
102
  Rich output on stderr, so it pipes:
102
103
 
103
104
  ```bash
@@ -118,6 +119,33 @@ The cap reads `usage.cost` from the response, so it works only where the provide
118
119
  says so when none is sent. Spend is printed and logged per run. In-flight requests can overshoot by
119
120
  up to the concurrency, so keep headroom on a hard limit.
120
121
 
122
+ ## Attack types
123
+
124
+ `attack_type` is free prose, which is useful to read and useless to group. Every result also carries a
125
+ closed-set slug (`vishing`, `smishing`, `tech-support`, `refund`, `gov-impersonation`, `bec`,
126
+ `romance`, `crypto`, `investment`, `utility`, `delivery`, `other`). Reports show counts and mean scores
127
+ per slug above the ranking, and `--type` filters to the ones you ask for:
128
+
129
+ ```bash
130
+ scamscript batch corpus/ --type tech-support,refund --output report.md
131
+ ```
132
+
133
+ The raw phrase is always kept next to the slug. An unrecognised description lands in `other` and the
134
+ CLI names any filter token it did not recognise.
135
+
136
+ ## Duplicates
137
+
138
+ Corpora are full of reprints. Each script is fingerprinted on its body: headers, casing, punctuation
139
+ and whitespace are ignored, so copies collapse. The strongest copy is ranked and the rest are listed
140
+ under it; the report counts what was folded and JSON records carry `fingerprint` and `duplicate_of`.
141
+
142
+ ```bash
143
+ scamscript batch corpus/ --output report.md # folds by default
144
+ scamscript batch corpus/ --no-dedupe --show-duplicates
145
+ ```
146
+
147
+ Only byte-level twins fold. A copy with one changed account number stays separate, by design.
148
+
121
149
  ## How it works
122
150
 
123
151
  ```text
@@ -154,7 +182,7 @@ masking is best effort: names, case IDs and spelled-out digits survive it.
154
182
  Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (override with
155
183
  `--home` or `SCAMSCRIPT_HOME`). The API key is never written. Logs are the source for `report`, so
156
184
  a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
157
- bounded.
185
+ bounded. Directories are `0700` and log files `0600`.
158
186
 
159
187
  ## Install and configure
160
188
 
@@ -226,7 +254,8 @@ docs/ calibration record, sample report, banner, demo
226
254
  - Breakdowns are model readings; a bad prompt or a weak model produces confident nonsense. Read
227
255
  `opening_hook` and `red_flags` against the source before quoting anything.
228
256
  - Redaction is regex-based and will miss names and identifiers without a fixed shape.
229
- - One script per request; no cross-script deduplication yet, so reprints inflate a ranking.
257
+ - Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
258
+ - Type slugs are keyword-matched, so an unusual description falls to `other` rather than inventing one.
230
259
  - The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
231
260
  - Analysis is English-centric: the prompt and schema assume English artifacts.
232
261
  - Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "scamscript"
7
- version = "0.4.1"
7
+ version = "0.6.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.4.1"
3
+ __version__ = "0.6.0"
@@ -7,7 +7,7 @@ from dataclasses import dataclass
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
- from scamscript import prompts, redact, verify
10
+ from scamscript import dedupe, prompts, redact, taxonomy, verify
11
11
  from scamscript.client import ChatClient, ClientError, TruncatedError
12
12
  from scamscript.ingest import ScriptFile
13
13
  from scamscript.models import BreakdownError, ScriptBreakdown, parse_breakdown
@@ -27,6 +27,9 @@ class Outcome:
27
27
  duration: float = 0.0
28
28
  redactions: dict[str, int] | None = None
29
29
  skipped: bool = False
30
+ fingerprint: str = ""
31
+ attack_slug: str = ""
32
+ duplicate_of: str | None = None
30
33
  issues: list[verify.Issue] | None = None
31
34
  verification: dict[str, int] | None = None
32
35
 
@@ -41,6 +44,9 @@ class Outcome:
41
44
  "name": self.name,
42
45
  "ok": self.ok,
43
46
  "skipped": self.skipped,
47
+ "fingerprint": self.fingerprint,
48
+ "attack_slug": self.attack_slug,
49
+ "duplicate_of": self.duplicate_of,
44
50
  "attempts": self.attempts,
45
51
  "duration_s": round(self.duration, 3),
46
52
  "breakdown": self.breakdown.model_dump() if self.breakdown else None,
@@ -63,7 +69,9 @@ def analyze_script(
63
69
  redact_identifiers: bool = False,
64
70
  verify_quotes: bool = True,
65
71
  ) -> Outcome:
66
- outcome = Outcome(source=script.path, name=script.name)
72
+ outcome = Outcome(
73
+ source=script.path, name=script.name, fingerprint=dedupe.fingerprint(script.text)
74
+ )
67
75
  started = time.monotonic()
68
76
 
69
77
  text = script.text
@@ -113,6 +121,7 @@ def analyze_script(
113
121
  try:
114
122
  outcome.breakdown = parse_breakdown(visible)
115
123
  outcome.error = None
124
+ outcome.attack_slug = taxonomy.slugify(outcome.breakdown.attack_type)
116
125
  if verify_quotes:
117
126
  outcome.issues = verify.verify_breakdown(outcome.breakdown, text)
118
127
  outcome.verification = verify.summarize(outcome.issues)
@@ -12,7 +12,7 @@ import typer
12
12
  from rich.console import Console
13
13
  from rich.table import Table
14
14
 
15
- from scamscript import __version__, prompts, redact, store
15
+ from scamscript import __version__, prompts, redact, store, taxonomy
16
16
  from scamscript import report as reporting
17
17
  from scamscript.analysis import Outcome, analyze_script
18
18
  from scamscript.client import ChatClient
@@ -52,6 +52,8 @@ class Context:
52
52
  json_output: bool
53
53
  verify_quotes: bool
54
54
  strict_quotes: bool
55
+ dedupe: bool
56
+ show_duplicates: bool
55
57
  console: Console
56
58
  err_console: Console
57
59
 
@@ -135,6 +137,16 @@ def main(
135
137
  "--strict-quotes",
136
138
  help="Exit non-zero when a quoted claim is not found in the source.",
137
139
  ),
140
+ dedupe: bool = typer.Option(
141
+ True,
142
+ "--dedupe/--no-dedupe",
143
+ help="Fold identical scripts into the strongest copy in reports.",
144
+ ),
145
+ show_duplicates: bool = typer.Option(
146
+ False,
147
+ "--show-duplicates",
148
+ help="Render a full panel for duplicate copies too.",
149
+ ),
138
150
  dry_run: bool = typer.Option(
139
151
  False,
140
152
  "--dry-run",
@@ -173,6 +185,8 @@ def main(
173
185
  json_output=json_output,
174
186
  verify_quotes=verify_quotes,
175
187
  strict_quotes=strict_quotes,
188
+ dedupe=dedupe,
189
+ show_duplicates=show_duplicates,
176
190
  console=Console(
177
191
  no_color=no_color,
178
192
  highlight=False,
@@ -220,6 +234,7 @@ def _run(
220
234
  batch_mode: bool,
221
235
  output: Path | None,
222
236
  top: int | None,
237
+ types: list[str] | None = None,
223
238
  ) -> None:
224
239
  console = state.console
225
240
  settings = state.settings
@@ -278,6 +293,7 @@ def _run(
278
293
  spent = 0.0
279
294
  spent_seen = False
280
295
  lock = threading.Lock()
296
+ seen: dict[str, str] = {}
281
297
  client = ChatClient(
282
298
  api_key=api_key,
283
299
  base_url=settings.base_url,
@@ -308,6 +324,11 @@ def _run(
308
324
  redact_identifiers=settings.redact,
309
325
  verify_quotes=state.verify_quotes,
310
326
  )
327
+ with lock:
328
+ if state.dedupe and outcome.fingerprint:
329
+ outcome.duplicate_of = seen.setdefault(outcome.fingerprint, outcome.name)
330
+ if outcome.duplicate_of == outcome.name:
331
+ outcome.duplicate_of = None
311
332
  cost = client.last_cost
312
333
  if cost:
313
334
  with lock:
@@ -320,6 +341,10 @@ def _run(
320
341
  _emit_json(outcome.as_record())
321
342
  elif outcome.skipped:
322
343
  console.print(f"[dim]skipped {outcome.name}: budget reached[/dim]")
344
+ elif outcome.duplicate_of and not state.show_duplicates:
345
+ console.print(
346
+ f"[dim]{outcome.name}: duplicate of {outcome.duplicate_of}[/dim]"
347
+ )
323
348
  elif outcome.breakdown is not None:
324
349
  console.print(
325
350
  render_breakdown(
@@ -388,14 +413,24 @@ def _run(
388
413
  cost_usd=round(spent, 6),
389
414
  )
390
415
 
416
+ only_types = taxonomy.parse_filter(types)
417
+ if only_types:
418
+ unknown = taxonomy.unknown(types)
419
+ if unknown:
420
+ console.print(
421
+ f"[yellow]note[/yellow] unknown type(s): {', '.join(sorted(unknown))}"
422
+ )
423
+ collapsed = reporting.collapse(report_entries) if state.dedupe else report_entries
424
+
391
425
  if batch_mode and (successes or skipped):
392
426
  if not state.json_output:
393
- console.print(
394
- render_summary_table(
395
- [(o.name, o.breakdown, o.error, o.skipped) for o in finished]
396
- )
427
+ rows = (
428
+ [(e.name, e.breakdown, e.error, e.skipped) for e in collapsed]
429
+ if state.dedupe
430
+ else [(o.name, o.breakdown, o.error, o.skipped) for o in finished]
397
431
  )
398
- ranked = reporting.rank(report_entries)
432
+ console.print(render_summary_table(rows))
433
+ ranked = reporting.rank(collapsed)
399
434
  if top:
400
435
  ranked = ranked[:top]
401
436
  markdown = reporting.build_markdown(
@@ -403,6 +438,7 @@ def _run(
403
438
  run_id=logger.run_id,
404
439
  command=command,
405
440
  model=settings.model,
441
+ only_types=only_types,
406
442
  )
407
443
  written = _emit_report(console, markdown, output, json_mode=state.json_output)
408
444
  if state.json_output:
@@ -469,6 +505,9 @@ def analyze(
469
505
  ),
470
506
  recursive: bool = typer.Option(False, "--recursive", "-r", help="Recurse into subdirectories."),
471
507
  top: int | None = typer.Option(None, "--top", min=1, help="Keep only the N most sophisticated scripts."),
508
+ types: list[str] | None = typer.Option(
509
+ None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
510
+ ),
472
511
  ) -> None:
473
512
  """Analyze a single script file, or a directory with --batch."""
474
513
  state = _context(ctx)
@@ -481,7 +520,15 @@ def analyze(
481
520
  except IngestError as exc:
482
521
  _fail(state.console, str(exc))
483
522
  return
484
- _run(state, scripts, command="analyze", batch_mode=batch_mode, output=output, top=top)
523
+ _run(
524
+ state,
525
+ scripts,
526
+ command="analyze",
527
+ batch_mode=batch_mode,
528
+ output=output,
529
+ top=top,
530
+ types=types,
531
+ )
485
532
 
486
533
 
487
534
  @app.command()
@@ -493,6 +540,9 @@ def batch(
493
540
  ),
494
541
  recursive: bool = typer.Option(False, "--recursive", "-r", help="Recurse into subdirectories."),
495
542
  top: int | None = typer.Option(None, "--top", min=1, help="Keep only the N most sophisticated scripts."),
543
+ types: list[str] | None = typer.Option(
544
+ None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
545
+ ),
496
546
  ) -> None:
497
547
  """Analyze a directory and rank every script by sophistication."""
498
548
  state = _context(ctx)
@@ -501,7 +551,7 @@ def batch(
501
551
  except IngestError as exc:
502
552
  _fail(state.console, str(exc))
503
553
  return
504
- _run(state, scripts, command="batch", batch_mode=True, output=output, top=top)
554
+ _run(state, scripts, command="batch", batch_mode=True, output=output, top=top, types=types)
505
555
 
506
556
 
507
557
  @app.command()
@@ -512,6 +562,9 @@ def report( # noqa: A001
512
562
  ),
513
563
  output: Path | None = typer.Option(None, "--output", "-o", help="Write the markdown here."),
514
564
  logs_dir: Path | None = typer.Option(None, "--logs-dir", help="Override the log directory."),
565
+ types: list[str] | None = typer.Option(
566
+ None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
567
+ ),
515
568
  ) -> None:
516
569
  """Regenerate the aggregated report from a past run's log."""
517
570
  state = _context(ctx)
@@ -538,6 +591,7 @@ def report( # noqa: A001
538
591
  run_id=str(run["run_id"]),
539
592
  command=str(run.get("command") or "report"),
540
593
  model=run.get("model"),
594
+ only_types=taxonomy.parse_filter(types),
541
595
  )
542
596
  _emit_report(state.console, markdown, output)
543
597
 
@@ -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.4.1",
66
+ "User-Agent": "scamscript/0.6.0",
67
67
  },
68
68
  )
69
69
 
@@ -0,0 +1,29 @@
1
+ """Content fingerprints for collapsing reprinted scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+
8
+ NON_WORD = re.compile(r"[^a-z0-9]+")
9
+ HEADER_LINE = re.compile(r"^\s*#")
10
+
11
+
12
+ def body(text: str) -> str:
13
+ """Drop the leading comment block a captured script usually carries."""
14
+ lines = text.splitlines()
15
+ index = 0
16
+ while index < len(lines) and (not lines[index].strip() or HEADER_LINE.match(lines[index])):
17
+ index += 1
18
+ return "\n".join(lines[index:])
19
+
20
+
21
+ def normalize(text: str) -> str:
22
+ lowered = body(text).lower()
23
+ return " ".join(NON_WORD.sub(" ", lowered).split())
24
+
25
+
26
+ def fingerprint(text: str) -> str:
27
+ """Short hash of the normalized body, stable across whitespace and punctuation."""
28
+ normalized = normalize(text)
29
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
@@ -9,6 +9,7 @@ from rich.table import Table
9
9
  from rich.text import Text
10
10
 
11
11
  from scamscript.ingest import ScriptFile
12
+ from scamscript import taxonomy
12
13
  from scamscript.models import ScriptBreakdown
13
14
  from scamscript.verify import Issue, summarize
14
15
 
@@ -123,6 +124,7 @@ def render_breakdown(
123
124
  style=f"bold {border_for(breakdown)}",
124
125
  )
125
126
  renderables.append(attack)
127
+ renderables.append(Text(f"slug: {taxonomy.slugify(breakdown.attack_type)}", style="dim"))
126
128
  renderables.append(Text(""))
127
129
 
128
130
  for key in (
@@ -7,6 +7,7 @@ from typing import Any
7
7
 
8
8
  from pydantic import ValidationError
9
9
 
10
+ from scamscript import taxonomy
10
11
  from scamscript.models import ScriptBreakdown
11
12
  from scamscript.store import results_of, utc_now
12
13
 
@@ -30,6 +31,9 @@ class ReportEntry:
30
31
  error: str | None = None
31
32
  duration: float = 0.0
32
33
  skipped: bool = False
34
+ fingerprint: str = ""
35
+ attack_slug: str = ""
36
+ duplicates: list[str] | None = None
33
37
  verification: dict[str, int] | None = None
34
38
  unverified: list[dict[str, str]] | None = None
35
39
 
@@ -44,6 +48,8 @@ def entry_from_outcome(outcome: Any) -> ReportEntry:
44
48
  source=str(getattr(outcome, "source", outcome.name)),
45
49
  breakdown=outcome.breakdown,
46
50
  skipped=getattr(outcome, "skipped", False),
51
+ fingerprint=getattr(outcome, "fingerprint", ""),
52
+ attack_slug=getattr(outcome, "attack_slug", ""),
47
53
  error=outcome.error,
48
54
  duration=getattr(outcome, "duration", 0.0),
49
55
  verification=getattr(outcome, "verification", None),
@@ -71,6 +77,9 @@ def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
71
77
  source=record.get("source") or "",
72
78
  breakdown=breakdown,
73
79
  skipped=bool(record.get("skipped")),
80
+ fingerprint=record.get("fingerprint") or "",
81
+ attack_slug=record.get("attack_slug") or "",
82
+ duplicates=record.get("duplicates") or None,
74
83
  error=None if breakdown else record.get("error") or "no breakdown stored",
75
84
  duration=float(record.get("duration_s") or 0.0),
76
85
  verification=record.get("verification") or None,
@@ -80,6 +89,29 @@ def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
80
89
  return entries
81
90
 
82
91
 
92
+ def collapse(entries: list[ReportEntry]) -> list[ReportEntry]:
93
+ """Keep the strongest copy of each fingerprint and list the rest under it."""
94
+ groups: dict[str, list[ReportEntry]] = {}
95
+ for entry in entries:
96
+ if entry.ok and entry.fingerprint:
97
+ groups.setdefault(entry.fingerprint, []).append(entry)
98
+
99
+ primaries: dict[str, ReportEntry] = {}
100
+ for key, group in groups.items():
101
+ if len(group) > 1:
102
+ best = max(group, key=lambda item: (item.breakdown.sophistication_score, item.breakdown.flag_count))
103
+ best.duplicates = [item.name for item in group if item is not best]
104
+ primaries[key] = best
105
+ else:
106
+ primaries[key] = group[0]
107
+
108
+ return [
109
+ primaries[entry.fingerprint] if entry.ok and entry.fingerprint else entry
110
+ for entry in entries
111
+ if not (entry.ok and entry.fingerprint and primaries[entry.fingerprint] is not entry)
112
+ ]
113
+
114
+
83
115
  def rank(entries: list[ReportEntry]) -> list[ReportEntry]:
84
116
  return sorted(
85
117
  entries,
@@ -96,7 +128,14 @@ def build_markdown(
96
128
  run_id: str,
97
129
  command: str = "batch",
98
130
  model: str | None = None,
131
+ only_types: set[str] | None = None,
99
132
  ) -> str:
133
+ if only_types:
134
+ entries = [
135
+ entry
136
+ for entry in entries
137
+ if not entry.ok or (entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)) in only_types
138
+ ]
100
139
  ranked = rank(entries)
101
140
  ok = [entry for entry in ranked if entry.ok]
102
141
  skipped = [entry for entry in ranked if entry.skipped and not entry.ok]
@@ -118,6 +157,9 @@ def build_markdown(
118
157
  lines.append(f"- Failed: {len(failed)}")
119
158
  if skipped:
120
159
  lines.append(f"- Skipped at budget: {len(skipped)}")
160
+ folded = sum(len(entry.duplicates or []) for entry in ok)
161
+ if folded:
162
+ lines.append(f"- Duplicate copies folded: {folded}")
121
163
  unverified_total = sum(len(entry.unverified or []) for entry in ok)
122
164
  if unverified_total:
123
165
  lines.append(f"- Unverified quotes: {unverified_total}")
@@ -126,15 +168,23 @@ def build_markdown(
126
168
  lines.append("")
127
169
 
128
170
  if ok:
171
+ lines.append("## Attack types")
172
+ lines.append("")
173
+ lines.append("| Type | Scripts | Mean score |")
174
+ lines.append("| --- | ---: | ---: |")
175
+ for slug, count, mean in _type_counts(ok):
176
+ lines.append(f"| {taxonomy.label(slug)} (`{slug}`) | {count} | {mean:.1f} |")
177
+ lines.append("")
129
178
  lines.append("## Ranking by sophistication")
130
179
  lines.append("")
131
- lines.append("| Rank | Script | Attack type | Sophistication | Red flags | Unverified |")
132
- lines.append("| ---: | --- | --- | ---: | ---: | ---: |")
180
+ lines.append("| Rank | Script | Type | Sophistication | Red flags | Unverified | Copies |")
181
+ lines.append("| ---: | --- | --- | ---: | ---: | ---: | ---: |")
133
182
  for position, entry in enumerate(ok, start=1):
134
183
  lines.append(
135
- f"| {position} | {_cell(entry.name)} | {_cell(entry.breakdown.attack_type)} | "
184
+ f"| {position} | {_cell(entry.name)} | "
185
+ f"`{entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)}` | "
136
186
  f"{entry.breakdown.sophistication_score}/10 | {entry.breakdown.flag_count} | "
137
- f"{len(entry.unverified or [])} |"
187
+ f"{len(entry.unverified or [])} | {1 + len(entry.duplicates or [])} |"
138
188
  )
139
189
  lines.append("")
140
190
  for position, entry in enumerate(ok, start=1):
@@ -172,8 +222,13 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
172
222
  assert breakdown is not None
173
223
  lines = [f"## {position}. {entry.name}", ""]
174
224
  lines.append(f"- **Attack type:** {breakdown.attack_type}")
225
+ lines.append(
226
+ f"- **Type slug:** `{entry.attack_slug or taxonomy.slugify(breakdown.attack_type)}`"
227
+ )
175
228
  lines.append(f"- **Sophistication:** {breakdown.sophistication_score}/10")
176
229
  lines.append(f"- **Red flags observed:** {breakdown.flag_count}")
230
+ if entry.duplicates:
231
+ lines.append(f"- **Identical copies folded:** {len(entry.duplicates)} ({', '.join(_cell(name) for name in entry.duplicates)})")
177
232
  counts = entry.verification or {}
178
233
  if counts.get("checked"):
179
234
  lines.append(
@@ -205,5 +260,19 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
205
260
  return lines
206
261
 
207
262
 
263
+ def _type_counts(entries: list[ReportEntry]) -> list[tuple[str, int, float]]:
264
+ buckets: dict[str, list[int]] = {}
265
+ for entry in entries:
266
+ if not entry.breakdown:
267
+ continue
268
+ slug = entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)
269
+ buckets.setdefault(slug, []).append(entry.breakdown.sophistication_score)
270
+ rows = [
271
+ (slug, len(scores), sum(scores) / len(scores))
272
+ for slug, scores in buckets.items()
273
+ ]
274
+ return sorted(rows, key=lambda row: (-row[1], row[0]))
275
+
276
+
208
277
  def _cell(value: str) -> str:
209
278
  return (value or "").replace("|", "\\|").replace("\n", " ").strip()
@@ -31,14 +31,23 @@ class RunLogger:
31
31
  self.command = command
32
32
  self.path = self.logs_dir / f"{self.run_id}.jsonl"
33
33
  self._started = False
34
+ self._locked_down = False
35
+
36
+ def _prepare(self) -> None:
37
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
38
+ for directory in (self.logs_dir.parent, self.logs_dir):
39
+ _chmod(directory, 0o700)
34
40
 
35
41
  def _write(self, record: dict[str, Any]) -> None:
36
42
  record.setdefault("ts", utc_now())
37
43
  record.setdefault("run_id", self.run_id)
38
44
  try:
39
- self.logs_dir.mkdir(parents=True, exist_ok=True)
45
+ self._prepare()
40
46
  with self.path.open("a", encoding="utf-8") as handle:
41
47
  handle.write(json.dumps(record, ensure_ascii=False) + "\n")
48
+ if not self._locked_down:
49
+ self._locked_down = True
50
+ _chmod(self.path, 0o600)
42
51
  except OSError:
43
52
  pass
44
53
 
@@ -145,9 +154,18 @@ def _mtime(path: Path) -> float:
145
154
  return 0.0
146
155
 
147
156
 
157
+ def _chmod(path: Path, mode: int) -> None:
158
+ try:
159
+ os.chmod(path, mode)
160
+ except OSError:
161
+ pass
162
+
163
+
148
164
  def ensure_home(home: str) -> None:
149
165
  try:
150
166
  os.makedirs(os.path.join(home, "logs"), exist_ok=True)
167
+ _chmod(Path(home), 0o700)
168
+ _chmod(Path(home) / "logs", 0o700)
151
169
  except OSError:
152
170
  pass
153
171
 
@@ -0,0 +1,77 @@
1
+ """Map free-text attack descriptions onto a fixed set of slugs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ OTHER = "other"
8
+
9
+ # Checked in order: the earlier, more specific family wins.
10
+ RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
11
+ ("bec", ("business email", "bec", "invoice fraud", "vendor payment", "mandate fraud", "ceo fraud")),
12
+ ("tech-support", ("tech support", "tech-support", "remote access", "windows security", "computer support")),
13
+ ("refund", ("refund", "overpayment", "rebate")),
14
+ ("gov-impersonation", ("government", "tax", "revenue", "irs", "hmrc", "dvla", "council", "police", "court")),
15
+ ("utility", ("utility", "water board", "energy", "electric", "gas board", "boiler", "home service")),
16
+ ("delivery", ("parcel", "delivery", "courier", "postage", "shipping")),
17
+ ("crypto", ("crypto", "bitcoin", "wallet", "token")),
18
+ ("investment", ("investment", "trading", "broker", "forex", "pig butchering")),
19
+ ("romance", ("romance", "dating", "long con", "long-con", "sweetheart")),
20
+ ("smishing", ("smishing", "sms", "text message", "text-message", "whatsapp")),
21
+ ("vishing", ("vishing", "phone call", "cold call", "robocall", "phone")),
22
+ )
23
+
24
+ SLUGS = tuple(slug for slug, _ in RULES) + (OTHER,)
25
+ NON_WORD = re.compile(r"[^a-z0-9]+")
26
+
27
+
28
+ def slugify(attack_type: str) -> str:
29
+ """Return a canonical slug for a model-written attack description."""
30
+ text = NON_WORD.sub(" ", (attack_type or "").lower())
31
+ for slug, needles in RULES:
32
+ if any(needle in text for needle in needles):
33
+ return slug
34
+ return OTHER
35
+
36
+
37
+ def unknown(values: list[str] | None) -> set[str]:
38
+ """Raw filter tokens that are not a known slug."""
39
+ if not values:
40
+ return set()
41
+ out: set[str] = set()
42
+ for value in values:
43
+ for chunk in value.split(","):
44
+ token = chunk.strip().lower()
45
+ if token and token not in SLUGS:
46
+ out.add(token)
47
+ return out
48
+
49
+
50
+ def label(slug: str) -> str:
51
+ return {
52
+ "bec": "Business email compromise",
53
+ "tech-support": "Tech support",
54
+ "refund": "Refund",
55
+ "gov-impersonation": "Government impersonation",
56
+ "utility": "Utility",
57
+ "delivery": "Delivery",
58
+ "crypto": "Crypto",
59
+ "investment": "Investment",
60
+ "romance": "Romance",
61
+ "smishing": "Smishing",
62
+ "vishing": "Vishing",
63
+ "other": "Other",
64
+ }.get(slug, slug)
65
+
66
+
67
+ def parse_filter(values: list[str] | None) -> set[str]:
68
+ """Turn a comma-separated --type option into a slug set."""
69
+ if not values:
70
+ return set()
71
+ out: set[str] = set()
72
+ for value in values:
73
+ for chunk in value.split(","):
74
+ slug = chunk.strip().lower()
75
+ if slug:
76
+ out.add(slugify(slug))
77
+ return out
@@ -0,0 +1,4 @@
1
+ # third copy, header differs only
2
+ CALLER: Good morning, this is the card security desk.
3
+ CALLER: We have blocked two transactions on your card ending 4417.
4
+ CALLER: Read me the last four digits and we will release the block.
@@ -0,0 +1,11 @@
1
+ # second copy, reformatted
2
+
3
+ # captured 2026-10-02, first copy
4
+
5
+ Caller — Good morning, this is the card security desk.
6
+
7
+ Caller — We have blocked two transactions on your card ending 4417!
8
+
9
+ Caller — Read me the last four digits and we will release the block.
10
+
11
+
@@ -0,0 +1,5 @@
1
+ # near twin: one extra sentence, different card digits
2
+ CALLER: Good morning, this is the card security desk.
3
+ CALLER: We have blocked two transactions on your card ending 9902.
4
+ CALLER: Read me the last four digits and we will release the block.
5
+ CALLER: If we do not clear it now, the block stays until you visit a branch.
@@ -0,0 +1,4 @@
1
+ # captured 2026-10-02, first copy
2
+ CALLER: Good morning, this is the card security desk.
3
+ CALLER: We have blocked two transactions on your card ending 4417.
4
+ CALLER: Read me the last four digits and we will release the block.
@@ -1,4 +1,6 @@
1
1
  import json
2
+ import re
3
+ from pathlib import Path
2
4
 
3
5
  import pytest
4
6
  from typer.testing import CliRunner
@@ -228,7 +230,7 @@ def test_dry_run_without_endpoint(home, tmp_path, monkeypatch):
228
230
  def test_version(home):
229
231
  result = runner.invoke(cli.app, ["--version"])
230
232
  assert result.exit_code == 0
231
- assert result.stdout.strip() == "scamscript 0.4.1"
233
+ assert result.stdout.strip() == "scamscript 0.6.0"
232
234
 
233
235
 
234
236
  def test_json_output_is_one_object_per_script(home, tmp_path, fake):
@@ -409,3 +411,70 @@ def test_max_cost_warns_when_provider_reports_nothing(home, tmp_path, fake):
409
411
  )
410
412
  assert result.exit_code == 0
411
413
  assert "could not be enforced" in result.stdout
414
+
415
+
416
+ def _reprints(tmp_path):
417
+ src = Path(__file__).parent / "fixtures" / "reprints"
418
+ for item in src.iterdir():
419
+ (tmp_path / item.name).write_text(item.read_text(), encoding="utf-8")
420
+
421
+
422
+ def test_dedupe_folds_reprints(home, tmp_path, fake):
423
+ _reprints(tmp_path)
424
+ scores = {"original": 4, "copy-punctuation": 9, "copy-header": 6, "near-twin": 8}
425
+ for key, score in scores.items():
426
+ fake.replies[key] = breakdown(score=score)
427
+ out = tmp_path / "r.md"
428
+ result = runner.invoke(cli.app, ["--api-key", "k", "batch", str(tmp_path), "--output", str(out)])
429
+ assert result.exit_code == 0
430
+ markdown = out.read_text()
431
+ assert "Duplicate copies folded: 2" in markdown
432
+ assert len(re.findall(r"^## \d+\. ", markdown, re.M)) == 2
433
+ assert "| 1 | copy-punctuation.txt |" in markdown
434
+ assert "near-twin.txt" in markdown
435
+ assert "copy-header.txt" in markdown
436
+
437
+
438
+ def test_no_dedupe_keeps_every_copy(home, tmp_path, fake):
439
+ _reprints(tmp_path)
440
+ out = tmp_path / "r.md"
441
+ result = runner.invoke(
442
+ cli.app, ["--api-key", "k", "--no-dedupe", "batch", str(tmp_path), "--output", str(out)]
443
+ )
444
+ assert result.exit_code == 0
445
+ text = out.read_text()
446
+ assert "Duplicate copies folded" not in text
447
+ assert len(re.findall(r"^\| \d+ \|", text, re.M)) == 4
448
+
449
+
450
+ def test_json_marks_duplicates(home, tmp_path, fake):
451
+ _reprints(tmp_path)
452
+ result = runner.invoke(cli.app, ["--api-key", "k", "--json", "batch", str(tmp_path)])
453
+ assert result.exit_code == 0
454
+ records = [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
455
+ results = [record for record in records if record.get("event") == "result"]
456
+ assert all(record["fingerprint"] for record in results)
457
+ assert sum(1 for record in results if record["duplicate_of"]) == 2
458
+
459
+
460
+ def test_type_filter_limits_report(home, tmp_path, fake):
461
+ (tmp_path / "a.txt").write_text("CALLER: vish", encoding="utf-8")
462
+ (tmp_path / "b.txt").write_text("CALLER: romance", encoding="utf-8")
463
+ fake.replies = {"vish": breakdown(attack="vishing"), "romance": breakdown(attack="romance scam")}
464
+ out = tmp_path / "r.md"
465
+ result = runner.invoke(
466
+ cli.app, ["--api-key", "k", "batch", str(tmp_path), "--type", "vishing", "--output", str(out)]
467
+ )
468
+ assert result.exit_code == 0
469
+ text = out.read_text()
470
+ assert "a.txt" in text
471
+ assert "b.txt" not in text
472
+
473
+
474
+ def test_unknown_type_warns(home, tmp_path, fake):
475
+ (tmp_path / "a.txt").write_text("CALLER: x", encoding="utf-8")
476
+ result = runner.invoke(
477
+ cli.app, ["--api-key", "k", "batch", str(tmp_path), "--type", "wibble"]
478
+ )
479
+ assert result.exit_code == 0
480
+ assert "unknown type" in result.stdout
@@ -0,0 +1,60 @@
1
+ from pathlib import Path
2
+
3
+ from scamscript.dedupe import fingerprint, normalize
4
+ from scamscript.report import ReportEntry, collapse
5
+
6
+ FIXTURES = Path(__file__).parent / "fixtures" / "reprints"
7
+
8
+
9
+ def read(name):
10
+ return (FIXTURES / name).read_text()
11
+
12
+
13
+ def test_header_and_punctuation_ignored():
14
+ assert fingerprint(read("original.txt")) == fingerprint(read("copy-punctuation.txt"))
15
+ assert fingerprint(read("original.txt")) == fingerprint(read("copy-header.txt"))
16
+
17
+
18
+ def test_near_twin_differs():
19
+ assert fingerprint(read("original.txt")) != fingerprint(read("near-twin.txt"))
20
+
21
+
22
+ def test_normalize_collapses_whitespace():
23
+ assert normalize("a b\n\tc") == "a b c"
24
+
25
+
26
+ def test_empty_and_header_only_stable():
27
+ assert fingerprint("") == fingerprint("# only a header\n")
28
+
29
+
30
+ def entry(name, score, fp="abc"):
31
+ from scamscript.models import ScriptBreakdown
32
+
33
+ breakdown = ScriptBreakdown(
34
+ attack_type="vishing",
35
+ persona="p",
36
+ opening_hook="h",
37
+ trust_building_moves=["a"],
38
+ urgency_tactics=["b"],
39
+ escalation_ladder=["c"],
40
+ target_demographics="d",
41
+ red_flags=["e"],
42
+ defense_summary="f",
43
+ sophistication_score=score,
44
+ )
45
+ return ReportEntry(name=name, source=name, breakdown=breakdown, fingerprint=fp)
46
+
47
+
48
+ def test_collapse_keeps_strongest_and_lists_rest():
49
+ entries = [entry("a", 4), entry("b", 9), entry("c", 6)]
50
+ collapsed = collapse(entries)
51
+ assert len(collapsed) == 1
52
+ assert collapsed[0].name == "b"
53
+ assert collapsed[0].duplicates == ["a", "c"]
54
+
55
+
56
+ def test_collapse_leaves_distinct_and_failed_alone():
57
+ entries = [entry("a", 5), entry("twin", 8, fp="zzz"), ReportEntry(name="bad", source="bad")]
58
+ collapsed = collapse(entries)
59
+ assert [item.name for item in collapsed] == ["a", "twin", "bad"]
60
+ assert collapsed[0].duplicates is None
@@ -31,10 +31,11 @@ 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 |" in markdown
34
+ assert "| 1 | a.txt | `vishing` | 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
38
+ assert "## Attack types" in markdown
38
39
  assert "security research and fraud-awareness" in markdown
39
40
 
40
41
 
@@ -77,3 +78,15 @@ def test_report_shows_unverified_quotes():
77
78
  assert "**Quote check:** 1/2 verbatim, 1 missing" in markdown
78
79
  assert "### Unverified quotes" in markdown
79
80
  assert "_missing_ [red flags] \"invented\"" in markdown
81
+
82
+
83
+ def test_type_filter_keeps_only_allowed_and_failures():
84
+ entries = [
85
+ ReportEntry(name="a.txt", source="a", breakdown=breakdown(9, attack="vishing"), attack_slug="vishing"),
86
+ ReportEntry(name="b.txt", source="b", breakdown=breakdown(5, attack="romance scam"), attack_slug="romance"),
87
+ ReportEntry(name="bad.txt", source="bad", error="boom"),
88
+ ]
89
+ markdown = build_markdown(entries, run_id="r", only_types={"vishing"})
90
+ assert "a.txt" in markdown
91
+ assert "b.txt" not in markdown
92
+ assert "bad.txt" in markdown
@@ -38,3 +38,13 @@ def test_prune_by_age(tmp_path):
38
38
 
39
39
  def test_prune_missing_dir(tmp_path):
40
40
  assert prune(str(tmp_path / "ghost")) == (0, 0, 0)
41
+
42
+
43
+ def test_log_dir_and_file_are_private(tmp_path):
44
+ from scamscript.store import RunLogger
45
+
46
+ log = RunLogger(str(tmp_path / "home" / "logs"), "analyze")
47
+ log.run_start(model="m")
48
+ assert oct((tmp_path / "home").stat().st_mode)[-3:] == "700"
49
+ assert oct((tmp_path / "home" / "logs").stat().st_mode)[-3:] == "700"
50
+ assert oct(log.path.stat().st_mode)[-3:] == "600"
@@ -0,0 +1,51 @@
1
+ import pytest
2
+
3
+ from scamscript.taxonomy import SLUGS, label, parse_filter, slugify
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ "text,slug",
8
+ [
9
+ ("government impersonation vishing (tax/revenue)", "gov-impersonation"),
10
+ ("vishing (automated robocall payment scam)", "vishing"),
11
+ ("romance scam / smishing long con", "romance"),
12
+ ("parcel delivery fee scam", "delivery"),
13
+ ("tech support scam with remote access", "tech-support"),
14
+ ("business email compromise by phone", "bec"),
15
+ ("crypto investment", "crypto"),
16
+ ("pig butchering investment scam", "investment"),
17
+ ("smishing text message", "smishing"),
18
+ ("utility impersonation", "utility"),
19
+ ("something entirely new", "other"),
20
+ ("", "other"),
21
+ ],
22
+ )
23
+ def test_mapping(text, slug):
24
+ assert slugify(text) == slug
25
+
26
+
27
+ def test_bec_wins_over_vishing():
28
+ assert slugify("vishing-led wire fraud, business email compromise") == "bec"
29
+
30
+
31
+ def test_case_and_punctuation_insensitive():
32
+ assert slugify("TECH-SUPPORT, remote access!") == "tech-support"
33
+
34
+
35
+ def test_slugs_are_closed_set():
36
+ assert "other" in SLUGS and "vishing" in SLUGS
37
+ assert slugify("nonsense") in SLUGS
38
+
39
+
40
+ def test_parse_filter_accepts_comma_and_spaces():
41
+ assert parse_filter(["tech support,romance", "vishing"]) == {
42
+ "tech-support",
43
+ "romance",
44
+ "vishing",
45
+ }
46
+ assert parse_filter(None) == set()
47
+
48
+
49
+ def test_label_known_and_fallback():
50
+ assert label("bec") == "Business email compromise"
51
+ assert label("odd") == "odd"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes