Security Audit v1 (#414)

This adds a security audit page with automatic checks for:

- Reused passwords
- Weak passwords
- Compromised passwords

Storing the audit information in the items themselves.

Co-authored-by: Martin Kleinschrodt <martin@maklesoft.com>
This commit is contained in:
Bruno Bernardino 2022-04-06 07:18:50 +01:00 committed by GitHub
parent 2f8c900a3e
commit 8ef09eb7be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 529 additions and 17 deletions

View File

@ -21,6 +21,7 @@ import "./org-view";
import "./settings";
import "./generator-view";
import "./invite-recipient";
import "./audit";
import "./support";
import "./menu";
import { registerPlatformAuthenticator, supportsPlatformAuthenticator } from "@padloc/core/src/platform";
@ -29,7 +30,8 @@ import { ProvisioningStatus } from "@padloc/core/src/provisioning";
import "./rich-content";
import { alertDisabledFeature, displayProvisioning, getDefaultStatusLabel } from "../lib/provisioning";
import { ItemsView } from "./items";
import { wait } from "@padloc/core/src/util";
import { wait, throttle } from "@padloc/core/src/util";
import { auditVaults } from "../lib/audit";
@customElement("pl-app")
export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLock(Routing(LitElement)))))) {
@ -59,6 +61,7 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
"orgs",
"invite",
"generator",
"audit",
"support",
];
@ -334,6 +337,8 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
<pl-generator-view ?hidden=${this._page !== "generator"}></pl-generator-view>
<pl-audit ?hidden=${this._page !== "audit"}></pl-audit>
<pl-support ?hidden=${this._page !== "support"}></pl-support>
<div
@ -361,6 +366,8 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
await app.logout();
this.go("start");
displayProvisioning(provisioning);
} else if (!this.state.locked) {
this._scheduleRunAudits();
}
}
@ -555,4 +562,13 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
target.shadowRoot?.querySelector("pl-totp")) as TOTPElement | null;
event.dataTransfer!.setData("text/plain", field.type === "totp" && totp ? totp.token : field.value);
}
private _scheduleRunAudits = throttle(() => {
this._maybeRunAudits();
}, 30000);
private async _maybeRunAudits() {
const { vaults } = this.state;
await auditVaults(vaults, { updateOnlyIfOutdated: true });
}
}

View File

@ -0,0 +1,120 @@
import { translate as $l } from "@padloc/locale/src/translate";
import { AuditResultType, VaultItem } from "@padloc/core/src/item";
import { customElement } from "lit/decorators.js";
import { css, html } from "lit";
import { app } from "../globals";
import { StateMixin } from "../mixins/state";
import { Routing } from "../mixins/routing";
import { View } from "./view";
import { descriptionForAudit, iconForAudit, titleTextForAudit } from "../lib/audit";
import { Vault } from "@padloc/core/src/vault";
@customElement("pl-audit")
export class Audit extends StateMixin(Routing(View)) {
readonly routePattern = /^audit/;
shouldUpdate() {
return !!app.account;
}
static styles = [
...View.styles,
css`
.counts {
display: grid;
grid-gap: 1em;
margin: 1em;
grid-template-columns: repeat(auto-fit, minmax(20em, 1fr));
}
@media (max-width: 700px) {
.counts {
grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
}
}
`,
];
render() {
let items = app.auditedItems;
return html`
<div class="fullbleed vertical layout">
<header class="padded spacing center-aligning horizontal layout">
<pl-button
class="transparent skinny menu-button header-title"
@click=${() =>
this.dispatchEvent(new CustomEvent("toggle-menu", { composed: true, bubbles: true }))}
>
<div class="half-margined horizontal spacing center-aligning layout text-left-aligning">
<pl-icon icon="audit-pass"></pl-icon>
<div class="stretch ellipsis">${$l("Security Audit")}</div>
</div>
</pl-button>
</header>
<div class="layout padded">
<div class="vertical spacing stretch">
<pl-scroller class="stretch">
<div class="counts">
${Object.values(AuditResultType).map((type) =>
this._renderSection(
items.filter(({ item }) => item.auditResults.some((res) => res.type === type)),
type
)
)}
</div>
</pl-scroller>
</div>
</div>
</div>
`;
}
private _renderSection(listItems: { item: VaultItem; vault: Vault }[], type: AuditResultType) {
return html`
<section class="box count">
<h2 class="bg-dark border-bottom center-aligning spacing horizontal layout">
<pl-icon icon="${iconForAudit(type)}" class="left-margined"></pl-icon>
<div class="uppercase semibold">${titleTextForAudit(type)}</div>
<div class="small bold subtle">${listItems.length}</div>
<div class="stretch"></div>
<pl-button class="subtle skinny transparent half-margined">
<pl-icon icon="info-round"></pl-icon>
</pl-button>
<pl-popover class="small double-padded max-width-20em"> ${descriptionForAudit(type)} </pl-popover>
</h2>
${listItems.length
? html`
<pl-list>
${listItems.slice(0, 5).map(
(listItem) => html`
<div
class="list-item hover click"
@click=${() => this.go(`items/${listItem.item.id}`)}
>
<pl-vault-item-list-item
.item=${listItem.item}
.vault=${listItem.vault}
class="none-interactive"
></pl-vault-item-list-item>
</div>
`
)}
</pl-list>
`
: html`
<div class="small double-padded subtle text-centering">
<pl-icon icon="audit-pass" class="inline"></pl-icon> ${$l("Nothing Found")}
</div>
`}
<pl-button
class="slim margined transparent"
@click=${() => this.go("items", { audit: type })}
?hidden=${listItems.length < 6}
>
<div>${$l("Show All")}</div>
<pl-icon icon="arrow-right" class="small left-margined"></pl-icon>
</pl-button>
</section>
`;
}
}

View File

@ -1,4 +1,4 @@
import { Field, FieldType, FIELD_DEFS } from "@padloc/core/src/item";
import { Field, FieldType, FIELD_DEFS, AuditResult, AuditResultType } from "@padloc/core/src/item";
import { translate as $l } from "@padloc/locale/src/translate";
import { shared } from "../styles";
import "./icon";
@ -14,6 +14,7 @@ import { css, html, LitElement } from "lit";
import { generatePassphrase } from "@padloc/core/src/diceware";
import { randomString, charSets } from "@padloc/core/src/util";
import { app } from "../globals";
import { descriptionForAudit, iconForAudit, titleTextForAudit } from "../lib/audit";
@customElement("pl-field")
export class FieldElement extends LitElement {
@ -29,6 +30,9 @@ export class FieldElement extends LitElement {
@property({ type: Boolean })
canMoveDown: boolean;
@property({ attribute: false })
auditResults: AuditResult[] = [];
@state()
private _masked: boolean = false;
@ -162,11 +166,14 @@ export class FieldElement extends LitElement {
css`
:host {
display: block;
opacity: 0.999;
position: relative;
background: var(--color-background);
}
:host(.dragging) {
opacity: 0.999;
}
.field-header {
--input-padding: 0.3em;
margin: 0.2em 0;
@ -391,6 +398,31 @@ export class FieldElement extends LitElement {
>
<div class="spacer" slot="before"></div>
<pl-icon icon="${this._fieldDef.icon}" slot="before"></pl-icon>
${Object.values(AuditResultType).map((type) =>
this.auditResults.some((res) => res.type == type)
? html`
<pl-icon
icon="${iconForAudit(type)}"
slot="after"
class="negative highlighted right-margined"
title=${titleTextForAudit(type)}
style="cursor: help;"
></pl-icon>
<pl-popover
trigger="hover"
class="double-padded max-width-20em"
style="text-transform: none; color: var(--color-foreground); pointer-events: none;"
slot="after"
>
<div class="large bold">
<pl-icon icon="${iconForAudit(type)}" class="inline"></pl-icon>
${titleTextForAudit(type)}
</div>
<div class="top-margined">${descriptionForAudit(type)}</div>
</pl-popover>
`
: ""
)}
</pl-input>
</div>

View File

@ -557,6 +557,26 @@ export class PlIcon extends LitElement {
:host([icon="frozen"]) > div::before {
content: "\\f2dc";
}
:host([icon="audit-pass"]) > div::before {
content: "\\f2f7";
}
:host([icon="audit-fail"]) > div::before {
content: "\\e24c";
}
:host([icon="weak"]) > div::before {
content: "\\f4bb";
}
:host([icon="reused"]) > div::before {
content: "\\f1b8";
}
:host([icon="compromised"]) > div::before {
content: "\\f21b";
}
`,
];

View File

@ -31,6 +31,7 @@ import "./attachment";
import { customElement, property, query, queryAll, state } from "lit/decorators.js";
import { css, html, LitElement } from "lit";
import { checkFeatureDisabled } from "../lib/provisioning";
import { auditVaults } from "../lib/audit";
@customElement("pl-item-view")
export class ItemView extends Routing(StateMixin(LitElement)) {
@ -395,11 +396,14 @@ export class ItemView extends Routing(StateMixin(LitElement)) {
(field) => `${this.itemId}_${field.name}_${field.type}`,
(field: Field, index: number) => html`
<pl-field
class="animated padded list-item"
class="padded list-item"
.canMoveUp=${!!index}
.canMoveDown=${index < this._fields.length - 1}
.field=${field}
.editing=${this._editing}
.auditResults=${this._item?.auditResults.filter(
(auditResult) => auditResult.fieldIndex === index
) || []}
@copy-clipboard=${() => this._copyToClipboard(this._item!, field)}
@remove=${() => this._removeField(index)}
@generate=${() => this._generateValue(index)}
@ -512,7 +516,10 @@ export class ItemView extends Routing(StateMixin(LitElement)) {
name: this._nameInput.value,
fields: [...this._fieldInputs].map((fieldEl: FieldElement) => fieldEl.field),
tags: this._tagsInput.tags,
auditResults: [],
lastAudited: undefined,
});
auditVaults([this._vault!], { updateOnlyItemWithId: this._item!.id });
this.go(`items/${this.itemId}`, undefined, undefined, true);
}

View File

@ -1,4 +1,4 @@
import { VaultItem, Field, Tag } from "@padloc/core/src/item";
import { VaultItem, Field, Tag, AuditResultType } from "@padloc/core/src/item";
import { Vault, VaultID } from "@padloc/core/src/vault";
import { translate as $l } from "@padloc/locale/src/translate";
import { debounce, wait, escapeRegex, truncate } from "@padloc/core/src/util";
@ -20,8 +20,10 @@ import { customElement, property, query, queryAll, state } from "lit/decorators.
import { css, html, LitElement } from "lit";
import { cache } from "lit/directives/cache.js";
import { Button } from "./button";
import "./item-icon";
import { iconForAudit, noItemsTextForAudit, titleTextForAudit } from "../lib/audit";
interface ListItem {
export interface ListItem {
item: VaultItem;
vault: Vault;
section?: string;
@ -37,6 +39,7 @@ export interface ItemsFilter {
attachments?: boolean;
recent?: boolean;
host?: boolean;
audit?: AuditResultType;
}
function filterByString(fs: string, rec: VaultItem) {
@ -48,7 +51,6 @@ function filterByString(fs: string, rec: VaultItem) {
.toLowerCase();
return content.search(escapeRegex(fs.toLowerCase())) !== -1;
}
import "./item-icon";
@customElement("pl-vault-item-list-item")
export class VaultItemListItem extends LitElement {
@ -266,6 +268,10 @@ export class VaultItemListItem extends LitElement {
:host(:not(:hover)) .move-right-button {
visibility: hidden;
}
:host(.none-interactive) * {
pointer-events: none !important;
}
`,
];
@ -629,7 +635,7 @@ export class ItemsList extends StateMixin(LitElement) {
];
render() {
const { favorites, recent, attachments, host, vault: vaultId, tag } = this.filter || {};
const { favorites, recent, attachments, host, vault: vaultId, tag, audit } = this.filter || {};
const placeholder = this._listItems.length
? {}
: this._filterShowing
@ -657,6 +663,11 @@ export class ItemsList extends StateMixin(LitElement) {
icon: "time",
text: $l("You don't have any recently used items!"),
}
: audit
? {
icon: "audit-pass",
text: noItemsTextForAudit(audit),
}
: {
icon: "vaults",
text: $l("You don't have any items yet."),
@ -682,6 +693,8 @@ export class ItemsList extends StateMixin(LitElement) {
? { title: vault.name, superTitle: org ? org.name : "", icon: "vaults" }
: tag
? { title: tag, superTitle: "", icon: "tags" }
: audit
? { title: titleTextForAudit(audit), superTitle: "", icon: iconForAudit(audit) }
: { title: $l("All Vaults"), superTitle: "", icon: "vaults" };
return html`
@ -914,7 +927,7 @@ export class ItemsList extends StateMixin(LitElement) {
}
private _getItems(): ListItem[] {
const { vault: vaultId, tag, favorites, attachments, recent, host } = this.filter || {};
const { vault: vaultId, tag, favorites, attachments, recent, host, audit } = this.filter || {};
const filter = (this._filterInput && this._filterInput.value) || "";
const recentThreshold = new Date(Date.now() - app.settings.recentLimit * 24 * 60 * 60 * 1000);
@ -939,7 +952,8 @@ export class ItemsList extends StateMixin(LitElement) {
(!favorites || app.account!.favorites.has(item.id)) &&
(!attachments || !!item.attachments.length) &&
(!recent ||
(app.state.lastUsed.has(item.id) && app.state.lastUsed.get(item.id)! > recentThreshold))
(app.state.lastUsed.has(item.id) && app.state.lastUsed.get(item.id)! > recentThreshold)) &&
(!audit || item.auditResults.some((auditResult) => auditResult.type === audit))
) {
items.push({
vault,

View File

@ -6,6 +6,7 @@ import "./item-view";
import { customElement, property, query } from "lit/decorators.js";
import { html } from "lit";
import { wait } from "@padloc/core/src/util";
import { AuditResultType } from "@padloc/core/src/item";
@customElement("pl-items")
export class ItemsView extends Routing(StateMixin(View)) {
@ -22,7 +23,7 @@ export class ItemsView extends Routing(StateMixin(View)) {
async handleRoute(
[id]: [string],
{ vault, tag, favorites, attachments, recent, host, search }: { [prop: string]: string }
{ vault, tag, favorites, attachments, recent, host, search, audit }: { [prop: string]: string }
) {
this.filter = {
vault,
@ -31,6 +32,7 @@ export class ItemsView extends Routing(StateMixin(View)) {
attachments: attachments === "true",
recent: recent === "true",
host: host === "true",
audit: audit as AuditResultType,
};
this.selected = id;

View File

@ -558,6 +558,18 @@ export class Menu extends Routing(StateMixin(LitElement)) {
<div class="stretch">${$l("Password Generator")}</div>
</div>
<div
class="menu-item"
@click=${() => this._goTo("audit")}
aria-selected=${this.selected === "audit"}
>
<pl-icon icon="audit-pass"></pl-icon>
<div class="stretch">${$l("Security Audit")}</div>
${count.audit ? html` <div class="small negative highlighted">${count.audit}</div> ` : ""}
</div>
<div
class="menu-item"
@click=${() => this._goTo("support")}

View File

@ -22,7 +22,7 @@ export async function request(
new Err(
ErrorCode.FAILED_CONNECTION,
$l(
"The app could not establish a connection with the our servers, please check your internet connection or try again later!"
"The app could not establish a connection with our servers, please check your internet connection or try again later!"
)
)
);

View File

@ -0,0 +1,255 @@
import { HashParams } from "@padloc/core/src/crypto";
import { bytesToHex, stringToBytes } from "@padloc/core/src/encoding";
import { AuditResult, AuditResultType, FieldType } from "@padloc/core/src/item";
import { getCryptoProvider } from "@padloc/core/src/platform";
import { Vault } from "@padloc/core/src/vault";
import { $l } from "@padloc/locale/src/translate";
import { sub } from "date-fns";
import { ListItem } from "../elements/items-list";
import { app } from "../globals";
import { passwordStrength } from "./util";
async function sha1(password: string) {
const hashedPasswordData = await getCryptoProvider().hash(
stringToBytes(password),
new HashParams({ algorithm: "SHA-1" })
);
const hashedPassword = bytesToHex(hashedPasswordData);
return hashedPassword;
}
async function isPasswordWeak(password: string) {
const { score } = await passwordStrength(password);
return score < 2;
}
async function hasPasswordBeenCompromised(passwordHash: string) {
const hashPrefix = passwordHash.slice(0, 5);
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`);
const result = await response.text();
const matchingHashSuffixes = result.split("\r\n");
for (const matchingHashSuffix of matchingHashSuffixes) {
const fullLowercaseHash = `${hashPrefix}${matchingHashSuffix.toLowerCase().split(":")[0]}`;
if (fullLowercaseHash === passwordHash) {
return true;
}
}
return false;
}
export async function auditVaults(
vaults: Vault[],
{
updateOnlyItemWithId,
updateOnlyIfOutdated,
}: { updateOnlyItemWithId?: string; updateOnlyIfOutdated?: boolean } = {}
) {
const reusedPasswords: ListItem[] = [];
const weakPasswords: ListItem[] = [];
const compromisedPasswords: ListItem[] = [];
// Don't try to run if the app has locked
if (app.state.locked) {
return {
reusedPasswords,
weakPasswords,
compromisedPasswords,
};
}
const usedPasswordHashCounts = new Map<string, number>();
const reusedPasswordItemIds: Set<string> = new Set();
const weakPasswordItemIds: Set<string> = new Set();
const compromisedPasswordItemIds: Set<string> = new Set();
// We need to do a run once for all the password hashes, to calculate reused afterwards, otherwise order can become a problem
for (const vault of vaults) {
for (const item of vault.items) {
const passwordFields = item.fields
.map((field, fieldIndex) => ({ field, fieldIndex }))
.filter((field) => field.field.type === FieldType.Password)
.filter((field) => Boolean(field.field.value));
for (const passwordField of passwordFields) {
const passwordHash = await sha1(passwordField.field.value);
const currentPasswordHashCount = usedPasswordHashCounts.get(passwordHash) || 0;
usedPasswordHashCounts.set(passwordHash, currentPasswordHashCount + 1);
}
}
}
const oneWeekAgo = sub(new Date(), { weeks: 1 });
let resultsFound = false;
for (const vault of vaults) {
let vaultResultsFound = false;
for (const item of vault.items) {
if (updateOnlyItemWithId) {
if (item.id !== updateOnlyItemWithId) {
continue;
}
}
if (updateOnlyIfOutdated && item.lastAudited && item.lastAudited >= oneWeekAgo) {
continue;
}
const passwordFields = item.fields
.map((field, fieldIndex) => ({ field, fieldIndex }))
.filter((field) => field.field.type === FieldType.Password)
.filter((field) => Boolean(field.field.value));
// If an item had password fields that failed audits and were since removed, we need to run the audit again to clear and update it
const itemHasFailedAudits = (item.auditResults || []).length > 0;
if (passwordFields.length === 0 && !itemHasFailedAudits) {
continue;
}
const auditResults: AuditResult[] = [];
for (const passwordField of passwordFields) {
const passwordHash = await sha1(passwordField.field.value);
// Perform reused audit (can't skip as it's interdependent)
if (usedPasswordHashCounts.get(passwordHash)! > 1) {
// Don't add the same item twice to the list, if there are more than one reused password fields in it
if (!reusedPasswordItemIds.has(item.id)) {
reusedPasswords.push({ item, vault });
reusedPasswordItemIds.add(item.id);
}
auditResults.push({
type: AuditResultType.ReusedPassword,
fieldIndex: passwordField.fieldIndex,
});
vaultResultsFound = true;
}
// Perform weak audit
const isThisPasswordWeak = await isPasswordWeak(passwordField.field.value);
if (isThisPasswordWeak) {
// Don't add the same item twice to the list, if there are more than one weak password fields in it
if (!weakPasswordItemIds.has(item.id)) {
weakPasswords.push({ item, vault });
weakPasswordItemIds.add(item.id);
}
auditResults.push({
type: AuditResultType.WeakPassword,
fieldIndex: passwordField.fieldIndex,
});
vaultResultsFound = true;
}
// Perform compromised audit
const isPasswordCompromised = await hasPasswordBeenCompromised(passwordHash);
if (isPasswordCompromised) {
// Don't add the same item twice to the list, if there are more than one compromised password fields in it
if (!compromisedPasswordItemIds.has(item.id)) {
compromisedPasswords.push({ item, vault });
compromisedPasswordItemIds.add(item.id);
}
auditResults.push({
type: AuditResultType.CompromisedPassword,
fieldIndex: passwordField.fieldIndex,
});
vaultResultsFound = true;
}
}
item.auditResults = auditResults;
item.lastAudited = new Date();
vault.items.update(item);
}
if (!vaultResultsFound) {
await app.saveVault(vault);
}
resultsFound = resultsFound || vaultResultsFound;
}
if (resultsFound) {
await app.save();
}
return {
reusedPasswords,
weakPasswords,
compromisedPasswords,
};
}
export function noItemsTextForAudit(type: AuditResultType) {
switch (type) {
case AuditResultType.WeakPassword:
return $l("You don't have any items with weak passwords!");
case AuditResultType.ReusedPassword:
return $l("You don't have any items with reused passwords!");
case AuditResultType.CompromisedPassword:
return $l("You don't have any items with compromised passwords!");
default:
return $l("You don't have any insecure items!");
}
}
export function titleTextForAudit(type: AuditResultType) {
switch (type) {
case AuditResultType.WeakPassword:
return $l("Weak Passwords");
case AuditResultType.ReusedPassword:
return $l("Reused Passwords");
case AuditResultType.CompromisedPassword:
return $l("Compromised Passwords");
default:
return $l("Insecure");
}
}
export function iconForAudit(type: AuditResultType) {
switch (type) {
case AuditResultType.WeakPassword:
return "weak";
case AuditResultType.ReusedPassword:
return "reused";
case AuditResultType.CompromisedPassword:
return "compromised";
default:
return "audit-fail";
}
}
export function descriptionForAudit(type: AuditResultType) {
switch (type) {
case AuditResultType.WeakPassword:
return $l(
"Passwords are considered weak if they're too short, don't have a lot of variation or contain commonly used words or phrases. These passwords generally don't offer enough protection against automated guessing attempts and should be replaced with strong, randomly generated passwords."
);
case AuditResultType.ReusedPassword:
return $l(
"Using the same password in multiple places is strongly discouraged as a data leak in one of those places will automatically compromise all other accounts/logins using the same password. We recommend generating strong, random and unique passwords for every single vault item."
);
case AuditResultType.CompromisedPassword:
return $l(
"Compromised passwords are those that have been identified as having been leaked in the past by comparing them against a database of known data breaches. These passwords can no longer be considered secure and should be changed immediately."
);
default:
"";
}
}

View File

@ -71,7 +71,7 @@ module.exports = {
meta: {
"Content-Security-Policy": {
"http-equiv": "Content-Security-Policy",
content: `default-src 'self' ${process.env.PL_SERVER_URL} blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
content: `default-src 'self' ${process.env.PL_SERVER_URL} https://api.pwnedpasswords.com blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
},
},
}),

View File

@ -4,7 +4,7 @@ import { Serializable, Serialize, AsDate, AsSerializable, bytesToBase64, stringT
import { Invite, InvitePurpose } from "./invite";
import { Vault, VaultID } from "./vault";
import { Org, OrgID, OrgMember, OrgRole, Group, UnlockedOrg, OrgInfo } from "./org";
import { VaultItem, VaultItemID, Field, Tag, createVaultItem } from "./item";
import { VaultItem, VaultItemID, Field, Tag, createVaultItem, AuditResult } from "./item";
import { Account, AccountID, UnlockedAccount } from "./account";
import { Auth } from "./auth";
import { Session, SessionID } from "./session";
@ -367,6 +367,18 @@ export class App {
return !!this.state.rememberedMasterKey;
}
get auditedItems() {
let items: { item: VaultItem; vault: Vault }[] = [];
for (const vault of this.vaults) {
for (const item of vault.items) {
if (item.auditResults?.length) {
items.push({ item, vault });
}
}
}
return items;
}
get count() {
const count = {
favorites: 0,
@ -376,6 +388,7 @@ export class App {
currentHost: this.state.context.browser?.url
? this.getItemsForUrl(this.state.context.browser.url).length
: 0,
audit: 0,
};
const recentThreshold = new Date(Date.now() - this.settings.recentLimit * 24 * 60 * 60 * 1000);
@ -391,6 +404,9 @@ export class App {
if (this.state.lastUsed.has(item.id) && this.state.lastUsed.get(item.id)! > recentThreshold) {
count.recent++;
}
if (item.auditResults?.length) {
count.audit++;
}
}
}
@ -1440,6 +1456,8 @@ export class App {
fields?: Field[];
tags?: Tag[];
attachments?: AttachmentInfo[];
auditResults?: AuditResult[];
lastAudited?: Date;
}
) {
const { vault } = this.getItem(item.id)!;

View File

@ -253,6 +253,17 @@ export function normalizeTag(tag: string): Tag {
return tag.replace(",", "");
}
export enum AuditResultType {
WeakPassword = "weak_password",
ReusedPassword = "reused_password",
CompromisedPassword = "compromised_password",
}
export interface AuditResult {
type: AuditResultType;
fieldIndex: number;
}
/** Represents an entry within a vault */
export class VaultItem extends Serializable {
constructor(vals: Partial<VaultItem> = {}) {
@ -292,6 +303,11 @@ export class VaultItem extends Serializable {
/** attachments associated with this item */
@AsSerializable(AttachmentInfo)
attachments: AttachmentInfo[] = [];
auditResults: AuditResult[] = [];
@AsDate()
lastAudited?: Date;
}
/** Creates a new vault item */

View File

@ -78,7 +78,7 @@ module.exports = {
meta: {
"Content-Security-Policy": {
"http-equiv": "Content-Security-Policy",
content: `default-src 'self' ${serverUrl} blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: *`,
content: `default-src 'self' ${serverUrl} https://api.pwnedpasswords.com blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: *`,
},
},
}),

View File

@ -72,7 +72,7 @@ module.exports = {
meta: {
"Content-Security-Policy": {
"http-equiv": "Content-Security-Policy",
content: `default-src 'self' ${serverUrl} blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
content: `default-src 'self' ${serverUrl} https://api.pwnedpasswords.com blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
},
},
}),

View File

@ -65,7 +65,7 @@ module.exports = {
meta: {
"Content-Security-Policy": {
"http-equiv": "Content-Security-Policy",
content: `default-src 'self' ${serverUrl} blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
content: `default-src 'self' ${serverUrl} https://api.pwnedpasswords.com blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
},
},
}),