Skip to content

Commit 701ec96

Browse files
Merge pull request #64851 from nextcloud/feat/noid/scheduled-background-code-integrity
feat: Add code integrity background job
2 parents fe2bc92 + ff440a6 commit 701ec96

11 files changed

Lines changed: 639 additions & 0 deletions

File tree

‎config/config.sample.php‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,15 @@
10321032
*/
10331033
'updatechecker' => true,
10341034

1035+
/**
1036+
* Re-run the code integrity check once a day in a background job and notify
1037+
* admins when its result changes, for example when a file inside the Nextcloud
1038+
* or app folders is modified or added.
1039+
*
1040+
* Defaults to ``true``
1041+
*/
1042+
'integrity.check.scheduled' => true,
1043+
10351044
/**
10361045
* URL that Nextcloud should use to look for updates
10371046
*

‎core/AppInfo/ConfigLexicon.php‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ class ConfigLexicon implements ILexicon {
4343

4444
public const APPSTORE_LINK_SHOWN = 'appstore_link_shown';
4545

46+
public const INTEGRITY_CHECK_NOTIFIED_RESULT = 'integrity_check_notified_result';
47+
4648
#[\Override]
4749
public function getStrictness(): Strictness {
4850
return Strictness::IGNORE;
@@ -129,6 +131,13 @@ public function getAppConfigs(): array {
129131
definition: 'Whether the repair step stripping trailing slashes from share targets has already been run.',
130132
lazy: true,
131133
),
134+
new Entry(
135+
key: self::INTEGRITY_CHECK_NOTIFIED_RESULT,
136+
type: ValueType::STRING,
137+
defaultRaw: '',
138+
definition: 'Fingerprint of the code integrity check result admins were last notified about, empty when the check passed.',
139+
lazy: true,
140+
),
132141
];
133142
}
134143

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OC\Core\BackgroundJobs;
11+
12+
use OC\Core\AppInfo\ConfigLexicon;
13+
use OC\IntegrityCheck\Checker;
14+
use OCP\AppFramework\Utility\ITimeFactory;
15+
use OCP\BackgroundJob\IJob;
16+
use OCP\BackgroundJob\TimedJob;
17+
use OCP\IAppConfig;
18+
use OCP\IConfig;
19+
use OCP\IGroupManager;
20+
use OCP\IL10N;
21+
use OCP\IURLGenerator;
22+
use OCP\IUser;
23+
use OCP\L10N\IFactory;
24+
use OCP\Mail\IMailer;
25+
use OCP\Notification\IManager as INotificationManager;
26+
use Psr\Log\LoggerInterface;
27+
28+
/**
29+
* Re-runs the code integrity check daily and notifies and emails admins whenever
30+
* its result differs from the one they were last notified about.
31+
* Disabled by setting `integrity.check.scheduled` to false.
32+
*/
33+
class CheckCodeIntegrityJob extends TimedJob {
34+
public const NOTIFICATION_OBJECT_TYPE = 'code_integrity';
35+
36+
public function __construct(
37+
ITimeFactory $time,
38+
private readonly Checker $checker,
39+
private readonly IConfig $config,
40+
private readonly IAppConfig $appConfig,
41+
private readonly IGroupManager $groupManager,
42+
private readonly INotificationManager $notificationManager,
43+
private readonly IMailer $mailer,
44+
private readonly IFactory $l10nFactory,
45+
private readonly IURLGenerator $urlGenerator,
46+
private readonly LoggerInterface $logger,
47+
) {
48+
parent::__construct($time);
49+
50+
$this->setInterval(24 * 60 * 60);
51+
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
52+
$this->setAllowParallelRuns(false);
53+
}
54+
55+
#[\Override]
56+
protected function run($argument): void {
57+
if (!$this->config->getSystemValueBool('integrity.check.scheduled', true)
58+
|| !$this->checker->isCodeCheckEnforced()) {
59+
return;
60+
}
61+
62+
$this->checker->runInstanceVerification();
63+
$results = $this->checker->getResults() ?? [];
64+
65+
$fingerprint = $this->fingerprint($results);
66+
$notifiedFingerprint = $this->appConfig->getValueString('core', ConfigLexicon::INTEGRITY_CHECK_NOTIFIED_RESULT, lazy: true);
67+
if ($fingerprint === $notifiedFingerprint) {
68+
return;
69+
}
70+
71+
if ($notifiedFingerprint !== '') {
72+
$notification = $this->notificationManager->createNotification();
73+
$notification->setApp('core')
74+
->setObject(self::NOTIFICATION_OBJECT_TYPE, $notifiedFingerprint);
75+
$this->notificationManager->markProcessed($notification);
76+
}
77+
78+
if ($fingerprint !== '') {
79+
$this->notifyAdmins($fingerprint, $this->summarize($results));
80+
}
81+
82+
$this->appConfig->setValueString('core', ConfigLexicon::INTEGRITY_CHECK_NOTIFIED_RESULT, $fingerprint, lazy: true);
83+
}
84+
85+
/**
86+
* Stable hash of the check result, or an empty string when the check passed
87+
*/
88+
private function fingerprint(array $results): string {
89+
$entries = [];
90+
foreach ($results as $scope => $scopeResult) {
91+
foreach ($scopeResult as $type => $details) {
92+
if ($type === 'EXCEPTION') {
93+
$entries[] = json_encode([$scope, $type, $details['message'] ?? ''], JSON_THROW_ON_ERROR);
94+
continue;
95+
}
96+
foreach ($details as $file => $hashes) {
97+
$entries[] = json_encode([$scope, $type, $file, $hashes['current'] ?? ''], JSON_THROW_ON_ERROR);
98+
}
99+
}
100+
}
101+
102+
if ($entries === []) {
103+
return '';
104+
}
105+
106+
// Scope and file order depend on filesystem iteration order
107+
sort($entries);
108+
return hash('sha256', implode("\n", $entries));
109+
}
110+
111+
/**
112+
* Number of files that failed, and the scopes (core or app ids) whose
113+
* signature could not be verified at all
114+
*
115+
* @return array{files: int, unverified: list<string>}
116+
*/
117+
private function summarize(array $results): array {
118+
$files = 0;
119+
$unverified = [];
120+
foreach ($results as $scope => $scopeResult) {
121+
foreach ($scopeResult as $type => $entries) {
122+
if ($type === 'EXCEPTION') {
123+
$unverified[] = (string)$scope;
124+
} elseif (is_array($entries)) {
125+
$files += count($entries);
126+
}
127+
}
128+
}
129+
sort($unverified);
130+
return ['files' => $files, 'unverified' => $unverified];
131+
}
132+
133+
/**
134+
* Human readable sentences for a summary, shared by the notification and the email
135+
*
136+
* @param array{files?: int, unverified?: list<string>} $summary
137+
* @return list<string>
138+
*/
139+
public static function formatSummary(IL10N $l, array $summary): array {
140+
$files = (int)($summary['files'] ?? 0);
141+
$unverified = $summary['unverified'] ?? [];
142+
143+
$sentences = [];
144+
if ($files > 0) {
145+
$sentences[] = $l->n(
146+
'%n file does not match the signed release. It may have been modified or added without authorization.',
147+
'%n files do not match the signed release. They may have been modified or added without authorization.',
148+
$files,
149+
);
150+
}
151+
if ($unverified !== []) {
152+
$sentences[] = $l->n(
153+
'The signature of %s is missing or invalid, so it could not be verified.',
154+
'The signatures of %s are missing or invalid, so they could not be verified.',
155+
count($unverified),
156+
[implode(', ', $unverified)],
157+
);
158+
}
159+
return $sentences;
160+
}
161+
162+
/**
163+
* @param array{files: int, unverified: list<string>} $summary
164+
*/
165+
private function notifyAdmins(string $fingerprint, array $summary): void {
166+
$admins = $this->groupManager->get('admin')?->getUsers() ?? [];
167+
if ($admins === []) {
168+
return;
169+
}
170+
171+
$notification = $this->notificationManager->createNotification();
172+
$notification->setApp('core')
173+
->setDateTime($this->time->getDateTime())
174+
->setObject(self::NOTIFICATION_OBJECT_TYPE, $fingerprint)
175+
->setSubject('code_integrity_changed', $summary);
176+
177+
foreach ($admins as $admin) {
178+
$notification->setUser($admin->getUID());
179+
$this->notificationManager->notify($notification);
180+
$this->sendMail($admin, $summary);
181+
}
182+
}
183+
184+
/**
185+
* @param array{files: int, unverified: list<string>} $summary
186+
*/
187+
private function sendMail(IUser $admin, array $summary): void {
188+
$email = $admin->getEMailAddress();
189+
if ($email === null || $email === '') {
190+
return;
191+
}
192+
193+
$l = $this->l10nFactory->get('core', $this->l10nFactory->getUserLanguage($admin));
194+
195+
$template = $this->mailer->createEMailTemplate('core.CodeIntegrityChanged', $summary);
196+
$template->setSubject($l->t('The code integrity check result has changed'));
197+
$template->addHeader();
198+
$template->addHeading($l->t('The code integrity check result has changed'));
199+
foreach (self::formatSummary($l, $summary) as $sentence) {
200+
$template->addBodyText($sentence);
201+
}
202+
$template->addBodyButton(
203+
$l->t('Review integrity check results'),
204+
$this->urlGenerator->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'overview']),
205+
);
206+
$template->addFooter();
207+
208+
$message = $this->mailer->createMessage();
209+
$message->setTo([$email => $admin->getDisplayName()]);
210+
$message->useTemplate($template);
211+
212+
try {
213+
$this->mailer->send($message);
214+
} catch (\Exception $e) {
215+
$this->logger->error('Could not send code integrity email to ' . $admin->getUID(), ['exception' => $e]);
216+
}
217+
}
218+
}

‎core/Notification/CoreNotifier.php‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace OC\Core\Notification;
1111

12+
use OC\Core\BackgroundJobs\CheckCodeIntegrityJob;
1213
use OCP\IConfig;
1314
use OCP\IURLGenerator;
1415
use OCP\L10N\IFactory;
@@ -76,6 +77,16 @@ public function prepare(INotification $notification, string $languageCode): INot
7677
return $notification;
7778
}
7879

80+
if ($notification->getSubject() === 'code_integrity_changed') {
81+
$sentences = CheckCodeIntegrityJob::formatSummary($l, $notification->getSubjectParameters());
82+
$sentences[] = $l->t('Review the results in the administration overview.');
83+
$notification->setParsedSubject($l->t('The code integrity check result has changed'));
84+
$notification->setParsedMessage(implode("\n", $sentences));
85+
$notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'overview']));
86+
$notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/error.svg')));
87+
return $notification;
88+
}
89+
7990
throw new UnknownNotificationException('Invalid subject');
8091
}
8192
}

‎lib/composer/composer/autoload_classmap.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1430,6 +1430,7 @@
14301430
'OC\\Core\\AppInfo\\Capabilities' => $baseDir . '/core/AppInfo/Capabilities.php',
14311431
'OC\\Core\\AppInfo\\ConfigLexicon' => $baseDir . '/core/AppInfo/ConfigLexicon.php',
14321432
'OC\\Core\\BackgroundJobs\\BackgroundCleanupUpdaterBackupsJob' => $baseDir . '/core/BackgroundJobs/BackgroundCleanupUpdaterBackupsJob.php',
1433+
'OC\\Core\\BackgroundJobs\\CheckCodeIntegrityJob' => $baseDir . '/core/BackgroundJobs/CheckCodeIntegrityJob.php',
14331434
'OC\\Core\\BackgroundJobs\\CheckForUserCertificates' => $baseDir . '/core/BackgroundJobs/CheckForUserCertificates.php',
14341435
'OC\\Core\\BackgroundJobs\\CleanupBackgroundJobsJob' => $baseDir . '/core/BackgroundJobs/CleanupBackgroundJobsJob.php',
14351436
'OC\\Core\\BackgroundJobs\\CleanupLoginFlowV2' => $baseDir . '/core/BackgroundJobs/CleanupLoginFlowV2.php',
@@ -2207,6 +2208,7 @@
22072208
'OC\\Repair' => $baseDir . '/lib/private/Repair.php',
22082209
'OC\\RepairException' => $baseDir . '/lib/private/RepairException.php',
22092210
'OC\\Repair\\AddBruteForceCleanupJob' => $baseDir . '/lib/private/Repair/AddBruteForceCleanupJob.php',
2211+
'OC\\Repair\\AddCheckCodeIntegrityJob' => $baseDir . '/lib/private/Repair/AddCheckCodeIntegrityJob.php',
22102212
'OC\\Repair\\AddCleanupBackgroundJobsJob' => $baseDir . '/lib/private/Repair/AddCleanupBackgroundJobsJob.php',
22112213
'OC\\Repair\\AddCleanupDeletedUsersBackgroundJob' => $baseDir . '/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php',
22122214
'OC\\Repair\\AddCleanupLoginTokens' => $baseDir . '/lib/private/Repair/AddCleanupLoginTokens.php',

‎lib/composer/composer/autoload_static.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,6 +1471,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
14711471
'OC\\Core\\AppInfo\\Capabilities' => __DIR__ . '/../../..' . '/core/AppInfo/Capabilities.php',
14721472
'OC\\Core\\AppInfo\\ConfigLexicon' => __DIR__ . '/../../..' . '/core/AppInfo/ConfigLexicon.php',
14731473
'OC\\Core\\BackgroundJobs\\BackgroundCleanupUpdaterBackupsJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/BackgroundCleanupUpdaterBackupsJob.php',
1474+
'OC\\Core\\BackgroundJobs\\CheckCodeIntegrityJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CheckCodeIntegrityJob.php',
14741475
'OC\\Core\\BackgroundJobs\\CheckForUserCertificates' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CheckForUserCertificates.php',
14751476
'OC\\Core\\BackgroundJobs\\CleanupBackgroundJobsJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CleanupBackgroundJobsJob.php',
14761477
'OC\\Core\\BackgroundJobs\\CleanupLoginFlowV2' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CleanupLoginFlowV2.php',
@@ -2248,6 +2249,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
22482249
'OC\\Repair' => __DIR__ . '/../../..' . '/lib/private/Repair.php',
22492250
'OC\\RepairException' => __DIR__ . '/../../..' . '/lib/private/RepairException.php',
22502251
'OC\\Repair\\AddBruteForceCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddBruteForceCleanupJob.php',
2252+
'OC\\Repair\\AddCheckCodeIntegrityJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCheckCodeIntegrityJob.php',
22512253
'OC\\Repair\\AddCleanupBackgroundJobsJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupBackgroundJobsJob.php',
22522254
'OC\\Repair\\AddCleanupDeletedUsersBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php',
22532255
'OC\\Repair\\AddCleanupLoginTokens' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupLoginTokens.php',

‎lib/private/Repair.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
namespace OC;
1010

1111
use OC\Repair\AddBruteForceCleanupJob;
12+
use OC\Repair\AddCheckCodeIntegrityJob;
1213
use OC\Repair\AddCleanupBackgroundJobsJob;
1314
use OC\Repair\AddCleanupDeletedUsersBackgroundJob;
1415
use OC\Repair\AddCleanupLoginTokens;
@@ -200,6 +201,7 @@ public static function getRepairSteps(bool $includeExpensive = false): array {
200201
Server::get(AddMovePreviewJob::class),
201202
Server::get(ConfigKeyMigration::class),
202203
Server::get(AddCleanupBackgroundJobsJob::class),
204+
Server::get(AddCheckCodeIntegrityJob::class),
203205
];
204206

205207
if ($includeExpensive) {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OC\Repair;
11+
12+
use OC\Core\BackgroundJobs\CheckCodeIntegrityJob;
13+
use OCP\BackgroundJob\IJobList;
14+
use OCP\Migration\IOutput;
15+
use OCP\Migration\IRepairStep;
16+
use Override;
17+
18+
class AddCheckCodeIntegrityJob implements IRepairStep {
19+
public function __construct(
20+
private readonly IJobList $jobList,
21+
) {
22+
}
23+
24+
#[\Override]
25+
public function getName(): string {
26+
return 'Add code integrity check background job';
27+
}
28+
29+
#[Override]
30+
public function run(IOutput $output): void {
31+
$this->jobList->add(CheckCodeIntegrityJob::class);
32+
}
33+
}

‎lib/private/Setup.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use OC\AppFramework\Bootstrap\Coordinator;
1717
use OC\Authentication\Token\PublicKeyTokenProvider;
1818
use OC\Authentication\Token\TokenCleanupJob;
19+
use OC\Core\BackgroundJobs\CheckCodeIntegrityJob;
1920
use OC\Core\BackgroundJobs\CleanupBackgroundJobsJob;
2021
use OC\Core\BackgroundJobs\ExpirePreviewsJob;
2122
use OC\Core\BackgroundJobs\GenerateMetadataJob;
@@ -536,6 +537,7 @@ public static function installBackgroundJobs(): void {
536537
$jobList->add(PreviewMigrationJob::class);
537538
$jobList->add(ExpirePreviewsJob::class);
538539
$jobList->add(CleanupBackgroundJobsJob::class);
540+
$jobList->add(CheckCodeIntegrityJob::class);
539541
}
540542

541543
/**

0 commit comments

Comments
 (0)