Skip to content

Commit 291d6fe

Browse files
authored
Merge pull request #1 from itk-dev/feature/sec-review-1
Fixed issues raised by security review
2 parents 88b4e00 + b1b9ee5 commit 291d6fe

7 files changed

Lines changed: 119 additions & 7 deletions

File tree

.claude/settings.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{
22
"permissions": {
33
"allow": [
4-
"WebFetch(domain:raw.githubusercontent.com)",
54
"Bash(task *)"
65
]
76
}

.github/workflows/security.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Security
2+
3+
env:
4+
COMPOSE_USER: runner
5+
6+
on:
7+
pull_request:
8+
paths:
9+
- "composer.json"
10+
- "docker-compose.yml"
11+
- ".github/workflows/security.yaml"
12+
push:
13+
branches:
14+
- main
15+
- develop
16+
paths:
17+
- "composer.json"
18+
- "docker-compose.yml"
19+
- ".github/workflows/security.yaml"
20+
schedule:
21+
# Weekly run picks up newly-published advisories against unchanged deps.
22+
- cron: "17 6 * * 1"
23+
24+
jobs:
25+
composer-audit:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@v6
29+
30+
- name: Create docker network
31+
run: |
32+
docker network create frontend
33+
34+
- run: |
35+
docker compose run --rm --no-deps phpfpm composer install
36+
docker compose run --rm --no-deps phpfpm composer audit

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,8 +346,7 @@ class User extends AbstractITKDevEntity implements AnonymizationStatusInterface
346346
```
347347

348348
Each PII field gets `#[ITKDev\EntityBundle\Privacy\Attribute\Anonymize(strategy: Strategy::Redact)]`. Strategies:
349-
`NullValue`, `Redact`, `Hash`, `Pseudonymize` (sha1 with a `kernel.secret`-derived pepper). The bundle scans these at
350-
compile time and ships two console commands:
349+
`NullValue`, `Redact`, `Hash`, `Pseudonymize`. The bundle scans these at compile time and ships two console commands:
351350

352351
- `bin/console privacy:anonymize <subjectUlid>` — right-to-erasure: scrubs PII on the subject's User row + every row
353352
that references them via a `ManyToOne(UserInterface)` association, plus rewrites the corresponding audit history.
@@ -358,6 +357,19 @@ compile time and ships two console commands:
358357

359358
The mechanism is law-neutral; the same machinery applies to GDPR, CCPA, LGPD, PIPEDA, etc.
360359

360+
### GDPR semantics of strategies
361+
362+
Not every strategy produces anonymous data. Pick deliberately:
363+
364+
- `NullValue`, `Redact`, `Hash` — **anonymization.** Output is unlinkable to the source value and to other rows
365+
scrubbed with the same strategy (`Hash` returns a fresh `random_bytes(32)`-derived token per call). Once applied, the
366+
resulting column is no longer personal data.
367+
- `Pseudonymize` — **pseudonymization.** Output is a deterministic short token derived from the cleartext and
368+
`kernel.secret`, so rows that shared a value still collide post-scrubbing. Use this when you need to preserve
369+
referential equality (e.g. correlating activity across tables) without retaining the cleartext. Under GDPR
370+
Recital 26 the result is **still personal data** — apply the same access controls as you would to the cleartext, and
371+
do not export it as "anonymized."
372+
361373
## Configuration
362374

363375
Everything is optional — see the reference table below for defaults.

SECURITY.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Security policy
2+
3+
## Supported versions
4+
5+
Security fixes are issued for the latest minor release on the `main` branch.
6+
Older versions are not maintained — upgrade to receive fixes.
7+
8+
## Reporting a vulnerability
9+
10+
Please **do not** open a public GitHub issue for security problems.
11+
12+
Report privately via GitHub's security advisory form:
13+
<https://github.com/itk-dev/entity-bundle/security/advisories/new>
14+
15+
Include:
16+
17+
- A description of the issue and its impact.
18+
- Steps to reproduce (a minimal failing case is ideal).
19+
- Affected versions, if known.
20+
- Your preferred contact for follow-up.
21+
22+
## Scope
23+
24+
This bundle handles personally identifiable data through its anonymization
25+
and audit features. Reports that materially affect the confidentiality or
26+
integrity of that data are in scope, including (non-exhaustive):
27+
28+
- Anonymization strategies that fail to anonymize as documented.
29+
- Audit log entries that retain personal data after scrubbing.
30+
- SQL filter bypasses that expose soft-deleted or archived rows.
31+
- Dependency vulnerabilities surfaced by the project's `composer audit` job.
32+
33+
Misuse by a host application (e.g. passing untrusted YAML into bundle
34+
configuration, exposing the privacy console commands to unauthenticated
35+
users) is out of scope; the bundle treats its configuration and CLI
36+
invocations as trusted.

src/Privacy/Strategy.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44

55
namespace ITKDev\EntityBundle\Privacy;
66

7+
/**
8+
* GDPR semantics of each strategy:
9+
*
10+
* - NullValue, Redact, Hash → anonymization. Output is unlinkable to the
11+
* source value and to other rows scrubbed with the same strategy. Once
12+
* applied, the resulting column is no longer personal data.
13+
* - Pseudonymize → pseudonymization. Output is deterministic in the source
14+
* value and kernel.secret; rows that shared a cleartext value still
15+
* collide post-scrubbing. The result remains personal data under GDPR
16+
* Recital 26 — treat with the same access controls as the cleartext.
17+
*/
718
enum Strategy: string
819
{
920
case NullValue = 'null';

src/Privacy/StrategyApplier.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ public function apply(Strategy $strategy, mixed $value, ?string $replacement = n
1919
return match ($strategy) {
2020
Strategy::NullValue => null,
2121
Strategy::Redact => $replacement ?? '[REDACTED]',
22-
Strategy::Hash => null === $value ? null : hash('sha256', (string) $value.$this->pepper),
22+
// Per-call random output — unlinkable to the source value and to any other
23+
// row anonymized with the same strategy. Pepper is intentionally unused here
24+
// so the result cannot be recomputed from the cleartext even by an operator
25+
// who knows kernel.secret.
26+
Strategy::Hash => null === $value ? null : bin2hex(random_bytes(32)),
2327
Strategy::Pseudonymize => null === $value
2428
? null
2529
: 'user_'.substr(hash('sha256', (string) $value.$this->pepper), 0, 12),

tests/Unit/Privacy/StrategyApplierTest.php

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,32 @@ public function testRedactWithReplacement(): void
3333
self::assertSame('[GONE]', $this->applier->apply(Strategy::Redact, 'value', '[GONE]'));
3434
}
3535

36-
public function testHashOfNonNullValue(): void
36+
public function testHashOfNonNullValueIsHex(): void
3737
{
38-
self::assertSame(hash('sha256', 'value'.'test-pepper'), $this->applier->apply(Strategy::Hash, 'value'));
38+
$result = $this->applier->apply(Strategy::Hash, 'value');
39+
40+
self::assertIsString($result);
41+
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $result);
3942
}
4043

4144
public function testHashOfNullReturnsNull(): void
4245
{
4346
self::assertNull($this->applier->apply(Strategy::Hash, null));
4447
}
4548

46-
public function testHashIsPepperDependent(): void
49+
public function testHashIsNonDeterministic(): void
50+
{
51+
$a = $this->applier->apply(Strategy::Hash, 'alice@example.com');
52+
$b = $this->applier->apply(Strategy::Hash, 'alice@example.com');
53+
54+
self::assertNotSame($a, $b);
55+
}
56+
57+
public function testHashIsUnlinkableToSource(): void
4758
{
59+
// Same source value across two different appliers (different peppers)
60+
// must still produce unrelated outputs — Hash does not depend on the
61+
// pepper, so determinism cannot leak via shared deployment secrets.
4862
$other = new StrategyApplier('different-pepper');
4963

5064
self::assertNotSame(

0 commit comments

Comments
 (0)