Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions .github/workflows/ci-browser-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,49 @@ jobs:
allow_issue_writing: false
artifact_name: zap-baseline-${{ github.run_id }}

- name: Test narrow ZAP bibliographic PII allowlist
run: bash tests/zap-pii-filter.test.sh

- name: Fail on medium or high ZAP alerts
run: |
test -s report_json.json || { echo "ZAP JSON report is missing"; exit 1; }
set -euo pipefail
identifiers_file="$RUNNER_TEMP/zap-catalogue-identifiers.json"
public_urls_file="$RUNNER_TEMP/zap-public-catalogue-urls.json"
public_examples_file="$RUNNER_TEMP/zap-public-example-identifiers.json"

MYSQL_PWD="$E2E_DB_PASS" mysql \
-h "$E2E_DB_HOST" -P "$E2E_DB_PORT" -u "$E2E_DB_USER" \
-N -B "$E2E_DB_NAME" -e '
SELECT identifier
FROM (
SELECT isbn13 AS identifier FROM libri WHERE deleted_at IS NULL
UNION
SELECT ean AS identifier FROM libri WHERE deleted_at IS NULL
) AS catalogue_identifiers
WHERE identifier REGEXP "^[0-9]{13}$"
ORDER BY identifier
' | jq -Rsc 'split("\n") | map(select(length > 0)) | unique' > "$identifiers_file"

# The single-quoted PHP program is intentional; its $variables must
# reach PHP literally rather than expand in the runner shell.
# shellcheck disable=SC2016
curl -fsS http://localhost:8081/sitemap.xml \
| php -r '
$xml = simplexml_load_string(stream_get_contents(STDIN), SimpleXMLElement::class, LIBXML_NONET);
if ($xml === false) { fwrite(STDERR, "Invalid sitemap XML\n"); exit(2); }
$urls = [];
foreach ($xml->url as $entry) { $urls[] = (string) $entry->loc; }
echo json_encode(array_values(array_unique($urls)), JSON_UNESCAPED_SLASHES), PHP_EOL;
' > "$public_urls_file"

# Translation dictionaries are intentionally inlined client-side.
# Extract only canonical, explicitly named ISBN example keys. The
# checked-in jq program requires a full 978/979 match and a valid
# ISBN-13 checksum, so unrelated numbers and longer substrings cannot
# become origin-wide exceptions.
jq -f scripts/ci-zap-public-isbn-examples.jq \
locale/it_IT.json > "$public_examples_file"

# OWASP ZAP rule 10062 (PII Disclosure) is allowlisted NARROWLY, never
# globally. A book catalogue renders ISBN/EAN-13 identifiers on its
# bibliographic pages, and a 13-digit code inherently collides with the
Expand All @@ -171,15 +211,15 @@ jobs:
# 413167 is a Visa range). An alert is ignored ONLY when EVERY instance
# is a 13-digit number on a bibliographic route — a real card leak
# (15/16 digits) or a 13-digit value on any other page still fails the
# build. Real secret/PII exposure is also covered by the secret-scanning,
# Semgrep and CodeQL jobs; every other medium/high alert stays blocking.
FILTER='def is_isbn_fp: (.pluginid // "") == "10062" and ([ .instances[]? | ((.evidence // "") | test("^[0-9]{13}$")) and ((.uri // "") | test("/(auteur|author|autor|autore|book|buch|catalog|catalogo|catalogue|editore|editori|genere|genre|katalog|libro|libri|livre|publisher|verlag)"; "i")) ] | (length > 0 and all)); [ .site[]?.alerts[]? | select((.riskcode | tonumber) >= 2) | select(is_isbn_fp | not) ]'
blocking=$(jq "$FILTER | length" report_json.json)
if [ "$blocking" -gt 0 ]; then
jq -r "$FILTER"' | .[] | "[" + (.riskdesc // "") + "] " + (.alert // "") + ": " + (.desc // "")' report_json.json
exit 1
fi
echo "ZAP found no blocking medium/high passive-scan alerts (allowlisted: 13-digit ISBN/EAN codes on bibliographic pages)"
# build. The checked-in filter also requires GET with empty param/attack,
# an exact registered route (canonical URLs come from the live sitemap)
# when evidence is a live catalogue identifier. The only route-wide
# exception is the exact public example strings shipped in the inlined
# translation dictionaries. Real secret/PII exposure is also covered
# by secret scanning, Semgrep and CodeQL; every other medium/high alert
# stays blocking.
bash scripts/ci-check-zap-report.sh \
report_json.json "$identifiers_file" "$public_urls_file" "$public_examples_file"

- name: Upload browser and server diagnostics
if: always()
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here.

## [0.7.64] - 2026-08-21

Optional multiple physical copies of the same title per borrower (#238).

### Features

- **Multiple copies per borrower** (opt-in, Settings → Loans → "Più copie dello
stesso titolo", off by default): staff can lend more than one distinct
physical copy of the same title to the same borrower. Every loan stays bound
to its own copy, so no two open loans for a borrower can share a copy.
Pending requests, reservations and legacy copyless rows keep the historical
borrower/title uniqueness, and per-copy overlap guards, eligibility, capacity
and the max-active-loans limit are all unchanged (each copy counts as one
active loan). The desk "Salva e registra un'altra copia" flow keeps the
selected title and focuses the next copy scan for fast batch check-outs.
Existing installations are unaffected without a migration — the setting
resolves to off until it is enabled, and fresh installs seed it off in all
five shipped locales.

## [0.7.63] - 2026-08-20

### Fixed
Expand Down
39 changes: 23 additions & 16 deletions app/Controllers/LoanApprovalController.php
Original file line number Diff line number Diff line change
Expand Up @@ -267,27 +267,19 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R
return $response->withHeader('Content-Type', 'application/json')->withStatus(403);
}

$dupStmt = $db->prepare("
SELECT id FROM prestiti
WHERE libro_id = ? AND utente_id = ? AND id != ?
AND (
(attivo = 1 AND stato IN ('prenotato', 'da_ritirare', 'in_corso', 'in_ritardo'))
OR (attivo = 0 AND stato = 'pendente')
)
LIMIT 1
");
$dupStmt->bind_param('iii', $libroId, $utenteId, $loanId);
$dupStmt->execute();
$hasActiveDuplicate = $dupStmt->get_result()->num_rows > 0;
$dupStmt->close();
if ($hasActiveDuplicate) {
// Approval atomically assigns a locked physical copy below, so the
// opt-in multiplicity policy may coexist with other copy-bound loans.
// Sibling pending/copyless rows remain blocking in every mode.
$multiplicityPolicy = new \App\Support\LoanMultiplicityPolicy($db);
if ($multiplicityPolicy->hasBlockingLoan($libroId, $utenteId, true, $loanId)) {
$db->rollback();
$response->getBody()->write(json_encode([
'success' => false,
'message' => __('L\'utente ha già un prestito attivo per questo libro')
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(409);
}
$borrowerCommittedCopyIds = $multiplicityPolicy->committedCopyIds($libroId, $utenteId, $loanId);

$dupReservationStmt = $db->prepare("
SELECT id
Expand Down Expand Up @@ -364,7 +356,7 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R
// If loan already has a valid assigned copy, we can skip global slot counting
$selectedCopy = null;

if ($existingCopiaId !== null) {
if ($existingCopiaId !== null && !in_array($existingCopiaId, $borrowerCommittedCopyIds, true)) {
$existingCopyStmt = $db->prepare("
SELECT c.id FROM copie c
WHERE c.id = ?
Expand Down Expand Up @@ -409,6 +401,17 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R
SELECT c.id FROM copie c
WHERE c.libro_id = ?
AND c.stato IN ('disponibile', 'prenotato')
AND NOT EXISTS (
SELECT 1 FROM prestiti own
WHERE own.copia_id = c.id
AND own.libro_id = ?
AND own.utente_id = ?
AND own.id != ?
AND (
(own.attivo = 0 AND own.stato = 'pendente')
OR (own.attivo = 1 AND own.stato IN ('prenotato', 'da_ritirare', 'in_corso', 'in_ritardo'))
)
)
AND NOT EXISTS (
SELECT 1 FROM prestiti p
WHERE p.copia_id = c.id
Expand All @@ -421,7 +424,7 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R
)
LIMIT 1
");
$overlapStmt->bind_param('iss', $libroId, $dataScadenza, $dataPrestito);
$overlapStmt->bind_param('iiiiss', $libroId, $libroId, $utenteId, $loanId, $dataScadenza, $dataPrestito);
$overlapStmt->execute();
$overlapResult = $overlapStmt->get_result();
$selectedCopy = $overlapResult ? $overlapResult->fetch_assoc() : null;
Expand All @@ -433,6 +436,10 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R
// Fallback: try date-aware method to find available copy for the requested period
$copyRepo = new \App\Models\CopyRepository($db);
$availableCopies = $copyRepo->getAvailableByBookIdForDateRange($libroId, $dataPrestito, $dataScadenza);
$availableCopies = array_values(array_filter(
$availableCopies,
static fn (array $copy): bool => !in_array((int) $copy['id'], $borrowerCommittedCopyIds, true)
));

if (empty($availableCopies)) {
$db->rollback();
Expand Down
Loading